feat: 初始提交(仅核心代码,已排除大文件)
@@ -0,0 +1,86 @@
|
|||||||
|
# ===== 开发依赖 =====
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# ===== Windows 独有 =====
|
||||||
|
winform/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# ===== 运行时数据(服务器重新生成)=====
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
temp/
|
||||||
|
.dbg/
|
||||||
|
exports/
|
||||||
|
backups/
|
||||||
|
Toolbox-Weekly-Backup/
|
||||||
|
|
||||||
|
# ===== 前端大文件/素材(服务器不需要)=====
|
||||||
|
public/images/korean_references/
|
||||||
|
public/images/zhihu/
|
||||||
|
public/images/wave_references/
|
||||||
|
public/images/x_fashion/
|
||||||
|
public/images/style_check_images/
|
||||||
|
public/home-bg/
|
||||||
|
public/tools/apple_watch/apple_health_export/
|
||||||
|
public/download_files/
|
||||||
|
public/temp_download/
|
||||||
|
|
||||||
|
# ===== 上传文件 =====
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# ===== 脚本 =====
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
# ===== 本地凭证(含密码/密钥,不上传)=====
|
||||||
|
#
|
||||||
|
# ★ 规则:所有密码、密钥、Token、用户名等高危内容,
|
||||||
|
# 一律放在 ~/Toolbox_local_creds.env.local 中,
|
||||||
|
# 禁止硬编码在源码里。
|
||||||
|
# env.local 已被本规则排除,config/ 目录同理。
|
||||||
|
#
|
||||||
|
# 使用姿势:在 index.js 入口自动加载 ~/Toolbox_local_creds.env.local
|
||||||
|
# 到 process.env,各模块通过 process.env.XXX 读取。
|
||||||
|
#
|
||||||
|
*.env.local
|
||||||
|
config/
|
||||||
|
public/tools/markets/auth_config.json
|
||||||
|
|
||||||
|
# ===== SSL 证书(服务器另有自己的)=====
|
||||||
|
umersoft.com_OV_SSL/
|
||||||
|
|
||||||
|
# ===== 调试/临时文件 =====
|
||||||
|
*.md
|
||||||
|
*.txt
|
||||||
|
cookies.txt
|
||||||
|
_fix_*.js
|
||||||
|
_verify*.js
|
||||||
|
_verify2*.js
|
||||||
|
check_*.js
|
||||||
|
debug-*.md
|
||||||
|
example_auto_*.js
|
||||||
|
get_cookie_*.js
|
||||||
|
OSS权限.txt
|
||||||
|
.tmp_db_path.js
|
||||||
|
tmp_*.js
|
||||||
|
reproduce_issue.js
|
||||||
|
verify_db*.js
|
||||||
|
~\$*.docx
|
||||||
|
|
||||||
|
# ===== 临时/备份文件 =====
|
||||||
|
- 副本*
|
||||||
|
*.backup
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# ===== IIS/构建配置(服务器不需要)=====
|
||||||
|
web.config
|
||||||
|
webpack.config.js
|
||||||
|
install_service_8977.bat
|
||||||
|
uninstall_service_8977.bat
|
||||||
|
|
||||||
|
# ===== 杂项 =====
|
||||||
|
.vscode/
|
||||||
|
query
|
||||||
|
service/
|
||||||
|
/tools/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"urlRandomness": "oVzuXCs"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { StatusBar, Alert, AppState, AppStateStatus } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { theme } from './theme';
|
||||||
|
import TodayScreen from './screens/TodayScreen';
|
||||||
|
import CalendarScreen from './screens/CalendarScreen';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { getEvents } from './api';
|
||||||
|
import { computeUpcomingReminders, EventForReminder } from './utils/recurrence';
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟容错窗口
|
||||||
|
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const notifiedKeysRef = useRef<Set<string>>(new Set());
|
||||||
|
const scheduledNotificationIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const heartbeatTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestNotificationPermissions();
|
||||||
|
initializeNotifications();
|
||||||
|
|
||||||
|
// 监听应用状态变化
|
||||||
|
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestNotificationPermissions = async () => {
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('提示', '需要通知权限才能发送提醒');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeNotifications = async () => {
|
||||||
|
await loadAndScheduleReminders();
|
||||||
|
startReminderHeartbeat();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
|
if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') {
|
||||||
|
// 应用从后台回到前台,重新检查提醒
|
||||||
|
loadAndScheduleReminders();
|
||||||
|
}
|
||||||
|
appStateRef.current = nextAppState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (heartbeatTimerRef.current) {
|
||||||
|
clearInterval(heartbeatTimerRef.current);
|
||||||
|
}
|
||||||
|
// 取消所有已安排的通知
|
||||||
|
scheduledNotificationIdsRef.current.forEach(async (id) => {
|
||||||
|
try {
|
||||||
|
await Notifications.cancelScheduledNotificationAsync(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('取消通知失败:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAndScheduleReminders = async () => {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const next24h = now + 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
// 加载事件(覆盖当前月和前后各一个月)
|
||||||
|
const currentDate = new Date();
|
||||||
|
const events = await getEvents(currentDate);
|
||||||
|
|
||||||
|
// 取消之前的所有通知
|
||||||
|
scheduledNotificationIdsRef.current.forEach(async (id) => {
|
||||||
|
try {
|
||||||
|
await Notifications.cancelScheduledNotificationAsync(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('取消通知失败:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scheduledNotificationIdsRef.current.clear();
|
||||||
|
|
||||||
|
// 补偿通知:检查过去 2 分钟内到期的提醒
|
||||||
|
catchUpNotifications(events, now);
|
||||||
|
|
||||||
|
// 安排未来 24 小时的通知
|
||||||
|
scheduleNotifications(events, now, next24h);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载提醒失败:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const catchUpNotifications = (events: EventForReminder[], now: number) => {
|
||||||
|
const since = now - WINDOW_MS;
|
||||||
|
events.forEach((e) => {
|
||||||
|
if (e.completed || e.deleted) return;
|
||||||
|
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||||
|
windowReminders.forEach((remindIso) => {
|
||||||
|
const key = `${e.id}|${remindIso}`;
|
||||||
|
if (!notifiedKeysRef.current.has(key)) {
|
||||||
|
triggerNotify(e, remindIso);
|
||||||
|
notifiedKeysRef.current.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNotifications = async (
|
||||||
|
events: EventForReminder[],
|
||||||
|
now: number,
|
||||||
|
next24h: number
|
||||||
|
) => {
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.completed || e.deleted) continue;
|
||||||
|
const upcoming = computeUpcomingReminders(e, now, next24h);
|
||||||
|
for (const remindIso of upcoming) {
|
||||||
|
const ts = new Date(remindIso).getTime();
|
||||||
|
const delay = ts - now;
|
||||||
|
if (delay > 0) {
|
||||||
|
const key = `${e.id}|${remindIso}`;
|
||||||
|
if (notifiedKeysRef.current.has(key)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notificationId = await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: '提醒',
|
||||||
|
body: e.title,
|
||||||
|
sound: true,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||||
|
seconds: delay / 1000,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
scheduledNotificationIdsRef.current.add(notificationId);
|
||||||
|
console.log('已安排通知:', e.title, '在', new Date(ts).toLocaleString());
|
||||||
|
} catch (e) {
|
||||||
|
console.error('安排通知失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerNotify = (e: EventForReminder, remindIso: string) => {
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: '提醒',
|
||||||
|
body: e.title,
|
||||||
|
sound: true,
|
||||||
|
},
|
||||||
|
trigger: null, // 立即触发
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startReminderHeartbeat = () => {
|
||||||
|
if (heartbeatTimerRef.current) {
|
||||||
|
clearInterval(heartbeatTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每分钟检查一次,捕捉睡眠/时间漂移导致的错过提醒
|
||||||
|
heartbeatTimerRef.current = setInterval(async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const since = now - WINDOW_MS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await getEvents(new Date());
|
||||||
|
events.forEach((e: EventForReminder) => {
|
||||||
|
if (e.completed || e.deleted) return;
|
||||||
|
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||||
|
windowReminders.forEach((remindIso) => {
|
||||||
|
const key = `${e.id}|${remindIso}`;
|
||||||
|
if (!notifiedKeysRef.current.has(key)) {
|
||||||
|
triggerNotify(e, remindIso);
|
||||||
|
notifiedKeysRef.current.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('心跳检查失败:', e);
|
||||||
|
}
|
||||||
|
}, 60 * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContainer>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor={theme.colors.background} />
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: theme.colors.primary,
|
||||||
|
tabBarInactiveTintColor: theme.colors.textSecondary,
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 24,
|
||||||
|
height: 70,
|
||||||
|
},
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
headerTintColor: theme.colors.text,
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Today"
|
||||||
|
component={TodayScreen}
|
||||||
|
options={{
|
||||||
|
title: '今日',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="today" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Calendar"
|
||||||
|
component={CalendarScreen}
|
||||||
|
options={{
|
||||||
|
title: '日历',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="calendar" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { Event, ApiResponse } from './types';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_calendar';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatError = (error: any): string => {
|
||||||
|
if (error.response) {
|
||||||
|
// The request was made and the server responded with a status code
|
||||||
|
// that falls out of the range of 2xx
|
||||||
|
return `服务器响应 ${error.response.status}: ${error.response.statusText}\n${JSON.stringify(error.response.data)}`;
|
||||||
|
} else if (error.request) {
|
||||||
|
// The request was made but no response was received
|
||||||
|
return `服务器无响应: 无法连接到 ${API_BASE_URL}\n请检查服务器是否已启动以及网络连接`;
|
||||||
|
} else if (error.message) {
|
||||||
|
// Something happened in setting up the request that triggered an Error
|
||||||
|
return `请求错误: ${error.message}`;
|
||||||
|
} else {
|
||||||
|
return `未知错误: ${JSON.stringify(error)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEvents = async (date?: Date): Promise<Event[]> => {
|
||||||
|
const year = date ? date.getFullYear() : undefined;
|
||||||
|
const month = date ? date.getMonth() + 1 : undefined;
|
||||||
|
return eventApi.getEvents(year, month);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eventApi = {
|
||||||
|
async getEvents(year?: number, month?: number): Promise<Event[]> {
|
||||||
|
try {
|
||||||
|
// 拉取更多月份的数据(为了包含所有可能的循环事件)
|
||||||
|
const requests: Promise<any>[] = [];
|
||||||
|
|
||||||
|
if (year && month) {
|
||||||
|
// 拉取前 6 个月和当前月的数据
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
let m = month - i;
|
||||||
|
let y = year;
|
||||||
|
while (m < 1) {
|
||||||
|
m += 12;
|
||||||
|
y--;
|
||||||
|
}
|
||||||
|
requests.push(api.get<ApiResponse<Event[]>>('/events', { params: { year: y, month: m } }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果没指定年月,拉取所有
|
||||||
|
requests.push(api.get<ApiResponse<Event[]>>('/events'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await Promise.all(requests);
|
||||||
|
|
||||||
|
// 合并并去重(按 id)
|
||||||
|
const allEvents: Event[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const response of responses) {
|
||||||
|
const events = response.data.data || [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (!seenIds.has(event.id)) {
|
||||||
|
seenIds.add(event.id);
|
||||||
|
allEvents.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEvents;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createEvent(event: Omit<Event, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.post('/events', event);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateEvent(id: string, event: Partial<Event>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.put('/events', { ...event, id }, { params: { id } });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteEvent(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.delete('/events', { params: { id } });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Calendar Reminder",
|
||||||
|
"slug": "calendar-reminder",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#0a0a14"
|
||||||
|
},
|
||||||
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#0a0a14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"sounds": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "6fd3e270-80f9-40d1-97f3-38fd1bd60afc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function(api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_calendar';
|
||||||
|
|
||||||
|
// 复制 recurrence 相关函数
|
||||||
|
function parseRecurrenceStr(str) {
|
||||||
|
const r = (str || '').trim();
|
||||||
|
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||||
|
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||||
|
const mInt = /interval=(\d+)/.exec(r);
|
||||||
|
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||||
|
const mBy = /by=([0-9,]+)/.exec(r);
|
||||||
|
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n >= 1 && n <= 7) : [];
|
||||||
|
return { type, interval, by };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDateStr(dateStr) {
|
||||||
|
if (dateStr.includes('T')) {
|
||||||
|
return dateStr.split('T')[0];
|
||||||
|
}
|
||||||
|
if (dateStr.includes(' ')) {
|
||||||
|
return dateStr.split(' ')[0];
|
||||||
|
}
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseDate(dateStr) {
|
||||||
|
try {
|
||||||
|
let date = new Date(dateStr);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (dateStr.includes(' ')) {
|
||||||
|
try {
|
||||||
|
let date = new Date(dateStr.replace(' ', 'T'));
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
const year = parseInt(match[1], 10);
|
||||||
|
const month = parseInt(match[2], 10) - 1;
|
||||||
|
const day = parseInt(match[3], 10);
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowEventOnDate(event, targetDate) {
|
||||||
|
const eventDate = extractDateStr(event.start);
|
||||||
|
const targetDateStr = targetDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (eventDate === targetDateStr) return true;
|
||||||
|
|
||||||
|
const evStart = safeParseDate(event.start);
|
||||||
|
const targetDayStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||||
|
const eventStartDay = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate());
|
||||||
|
|
||||||
|
if (event.end) {
|
||||||
|
const evEnd = safeParseDate(event.end);
|
||||||
|
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||||
|
|
||||||
|
if (targetDayStart >= eventStartDay && targetDayStart <= eventEndDay) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = parseRecurrenceStr(event.recurrence);
|
||||||
|
if (rec.type === 'NONE') return false;
|
||||||
|
|
||||||
|
if (targetDayStart < eventStartDay) return false;
|
||||||
|
|
||||||
|
if (event.end) {
|
||||||
|
const evEnd = safeParseDate(event.end);
|
||||||
|
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||||
|
if (targetDayStart > eventEndDay) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rec.type === 'DAILY') {
|
||||||
|
const diffDays = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (24 * 3600 * 1000));
|
||||||
|
return diffDays % rec.interval === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rec.type === 'WEEKLY') {
|
||||||
|
const weeksSinceStart = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (7 * 24 * 3600 * 1000));
|
||||||
|
const weekday = targetDayStart.getDay() === 0 ? 7 : targetDayStart.getDay();
|
||||||
|
const by = rec.by.length ? rec.by : [(safeParseDate(event.start).getDay() || 7)];
|
||||||
|
return by.includes(weekday) && (weeksSinceStart % rec.interval === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEvents(year, month) {
|
||||||
|
const response = await axios.get(`${API_BASE_URL}/events`, { params: { year, month } });
|
||||||
|
return response.data.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTodayTabEvents(targetDate) {
|
||||||
|
console.log('加载【今日】Tab 数据...');
|
||||||
|
const allEvents = await getEvents(targetDate.getFullYear(), targetDate.getMonth() + 1);
|
||||||
|
const todayEvents = allEvents.filter(event => {
|
||||||
|
if (event.deleted) return false;
|
||||||
|
return shouldShowEventOnDate(event, targetDate);
|
||||||
|
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||||
|
|
||||||
|
return todayEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCalendarTabEvents(targetDate) {
|
||||||
|
console.log('加载【日历】Tab 数据...');
|
||||||
|
const allEvents = [];
|
||||||
|
const seenIds = new Set();
|
||||||
|
|
||||||
|
for (let i = -6; i <= 1; i++) {
|
||||||
|
let tempDate = new Date(targetDate);
|
||||||
|
tempDate.setMonth(tempDate.getMonth() + i);
|
||||||
|
const year = tempDate.getFullYear();
|
||||||
|
const month = tempDate.getMonth() + 1;
|
||||||
|
|
||||||
|
const monthEvents = await getEvents(year, month);
|
||||||
|
for (const event of monthEvents) {
|
||||||
|
if (!seenIds.has(event.id)) {
|
||||||
|
seenIds.add(event.id);
|
||||||
|
allEvents.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validEvents = allEvents.filter(e => !e.deleted);
|
||||||
|
const targetEvents = validEvents.filter(event => {
|
||||||
|
return shouldShowEventOnDate(event, targetDate);
|
||||||
|
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||||
|
|
||||||
|
return targetEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compare() {
|
||||||
|
console.log('========================================');
|
||||||
|
console.log('对比 2026-06-29 两个 Tab 的事件');
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
const targetDate = new Date(2026, 5, 29);
|
||||||
|
|
||||||
|
const [todayEvents, calendarEvents] = await Promise.all([
|
||||||
|
loadTodayTabEvents(targetDate),
|
||||||
|
loadCalendarTabEvents(targetDate)
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('\n===== 【今日】Tab 2026-06-29 事件 =====');
|
||||||
|
console.log('总数:', todayEvents.length);
|
||||||
|
todayEvents.forEach((event, index) => {
|
||||||
|
console.log(`${index + 1}. ${event.title} (id: ${event.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n===== 【日历】Tab 2026-06-29 事件 =====');
|
||||||
|
console.log('总数:', calendarEvents.length);
|
||||||
|
calendarEvents.forEach((event, index) => {
|
||||||
|
console.log(`${index + 1}. ${event.title} (id: ${event.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n===== 差异对比 =====');
|
||||||
|
const todayIds = new Set(todayEvents.map(e => e.id));
|
||||||
|
const calendarIds = new Set(calendarEvents.map(e => e.id));
|
||||||
|
|
||||||
|
const onlyInToday = todayEvents.filter(e => !calendarIds.has(e.id));
|
||||||
|
const onlyInCalendar = calendarEvents.filter(e => !todayIds.has(e.id));
|
||||||
|
|
||||||
|
if (onlyInToday.length === 0 && onlyInCalendar.length === 0) {
|
||||||
|
console.log('✅ 两个 Tab 的事件完全一致!');
|
||||||
|
} else {
|
||||||
|
if (onlyInToday.length > 0) {
|
||||||
|
console.log(`❌ 只在【今日】Tab 中的事件 (${onlyInToday.length} 个):`);
|
||||||
|
onlyInToday.forEach((event, index) => {
|
||||||
|
console.log(` ${index + 1}. ${event.title} (id: ${event.id})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onlyInCalendar.length > 0) {
|
||||||
|
console.log(`❌ 只在【日历】Tab 中的事件 (${onlyInCalendar.length} 个):`);
|
||||||
|
onlyInCalendar.forEach((event, index) => {
|
||||||
|
console.log(` ${index + 1}. ${event.title} (id: ${event.id})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n========================================');
|
||||||
|
}
|
||||||
|
|
||||||
|
compare().catch(console.error);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
import { Event } from '../types';
|
||||||
|
import { safeParseDate } from '../utils/recurrence';
|
||||||
|
|
||||||
|
interface EventCardProps {
|
||||||
|
event: Event;
|
||||||
|
onEdit: (event: Event) => void;
|
||||||
|
onToggleComplete: (event: Event) => void;
|
||||||
|
onDelete: (event: Event) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventCard({
|
||||||
|
event,
|
||||||
|
onEdit,
|
||||||
|
onToggleComplete,
|
||||||
|
onDelete,
|
||||||
|
}: EventCardProps) {
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = safeParseDate(dateString);
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.container, event.completed && styles.completedContainer]}
|
||||||
|
onPress={() => onEdit(event)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.checkbox}
|
||||||
|
onPress={() => onToggleComplete(event)}
|
||||||
|
>
|
||||||
|
{event.completed ? (
|
||||||
|
<Ionicons name="checkmark-circle" size={28} color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.checkboxEmpty} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.title,
|
||||||
|
event.completed && styles.completedText,
|
||||||
|
]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</Text>
|
||||||
|
{event.content ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.contentText,
|
||||||
|
event.completed && styles.completedText,
|
||||||
|
]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{event.content}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.timeContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name="time-outline"
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.timeText}>{formatTime(event.start)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.deleteButton}
|
||||||
|
onPress={() => onDelete(event)}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={20} color={theme.colors.error} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.surfaceVariant,
|
||||||
|
},
|
||||||
|
completedContainer: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
checkbox: {
|
||||||
|
marginRight: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
checkboxEmpty: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
contentText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
completedText: {
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
},
|
||||||
|
timeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
...theme.typography.caption,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
padding: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
import { Event } from '../types';
|
||||||
|
import { safeParseDate } from '../utils/recurrence';
|
||||||
|
|
||||||
|
interface EventModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
event: Event | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (event: Partial<Event>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventModal({
|
||||||
|
visible,
|
||||||
|
event,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: EventModalProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState(new Date());
|
||||||
|
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||||
|
const [enableReminder, setEnableReminder] = useState(true);
|
||||||
|
const [remindDate, setRemindDate] = useState(new Date());
|
||||||
|
const [recurrenceType, setRecurrenceType] = useState<'NONE' | 'DAILY' | 'WEEKLY'>('NONE');
|
||||||
|
const [showStartPicker, setShowStartPicker] = useState(false);
|
||||||
|
const [showEndPicker, setShowEndPicker] = useState(false);
|
||||||
|
const [showRemindPicker, setShowRemindPicker] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (event) {
|
||||||
|
setTitle(event.title);
|
||||||
|
setContent(event.content || '');
|
||||||
|
setStartDate(safeParseDate(event.start));
|
||||||
|
setEndDate(event.end ? safeParseDate(event.end) : null);
|
||||||
|
setEnableReminder(!!event.remindAt);
|
||||||
|
setRemindDate(event.remindAt ? safeParseDate(event.remindAt) : new Date());
|
||||||
|
|
||||||
|
if (event.recurrence?.startsWith('DAILY')) {
|
||||||
|
setRecurrenceType('DAILY');
|
||||||
|
} else if (event.recurrence?.startsWith('WEEKLY')) {
|
||||||
|
setRecurrenceType('WEEKLY');
|
||||||
|
} else {
|
||||||
|
setRecurrenceType('NONE');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}, [event, visible]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setTitle('');
|
||||||
|
setContent('');
|
||||||
|
setStartDate(new Date());
|
||||||
|
setEndDate(null);
|
||||||
|
setEnableReminder(true);
|
||||||
|
setRemindDate(new Date());
|
||||||
|
setRecurrenceType('NONE');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
alert('请输入标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recurrence = '';
|
||||||
|
if (recurrenceType === 'DAILY') {
|
||||||
|
recurrence = 'DAILY;interval=1';
|
||||||
|
} else if (recurrenceType === 'WEEKLY') {
|
||||||
|
recurrence = 'WEEKLY;interval=1';
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData: Partial<Event> = {
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
start: startDate.toISOString(),
|
||||||
|
end: endDate ? endDate.toISOString() : null,
|
||||||
|
allDay: false,
|
||||||
|
remindAt: enableReminder ? remindDate.toISOString() : null,
|
||||||
|
recurrence: recurrence || null,
|
||||||
|
completed: event?.completed || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
onSave(eventData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={onClose}>
|
||||||
|
<Ionicons name="close" size={28} color={theme.colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>
|
||||||
|
{event ? '编辑事件' : '新建事件'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={handleSave}>
|
||||||
|
<Text style={styles.saveButton}>保存</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>标题</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
placeholder="输入事件标题"
|
||||||
|
placeholderTextColor={theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>内容</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.textArea]}
|
||||||
|
value={content}
|
||||||
|
onChangeText={setContent}
|
||||||
|
placeholder="输入事件内容(可选)"
|
||||||
|
placeholderTextColor={theme.colors.textSecondary}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>开始时间</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dateButton}
|
||||||
|
onPress={() => setShowStartPicker(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.dateButtonText}>{formatDate(startDate)}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>结束时间(可选)</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dateButton}
|
||||||
|
onPress={() => setShowEndPicker(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.dateButtonText}>
|
||||||
|
{endDate ? formatDate(endDate) : '未设置'}
|
||||||
|
</Text>
|
||||||
|
{endDate && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setEndDate(null)}
|
||||||
|
style={styles.clearButton}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={20} color={theme.colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.switchField}>
|
||||||
|
<Text style={styles.label}>启用提醒</Text>
|
||||||
|
<Switch
|
||||||
|
value={enableReminder}
|
||||||
|
onValueChange={setEnableReminder}
|
||||||
|
trackColor={{ false: theme.colors.border, true: theme.colors.primary }}
|
||||||
|
thumbColor={theme.colors.text}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{enableReminder && (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>提醒时间</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dateButton}
|
||||||
|
onPress={() => setShowRemindPicker(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.dateButtonText}>{formatDate(remindDate)}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>重复</Text>
|
||||||
|
<View style={styles.recurrenceOptions}>
|
||||||
|
{[
|
||||||
|
{ label: '不重复', value: 'NONE' },
|
||||||
|
{ label: '每天', value: 'DAILY' },
|
||||||
|
{ label: '每周', value: 'WEEKLY' },
|
||||||
|
].map((option) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.value}
|
||||||
|
style={[
|
||||||
|
styles.recurrenceButton,
|
||||||
|
recurrenceType === option.value && styles.recurrenceButtonActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setRecurrenceType(option.value as any)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.recurrenceButtonText,
|
||||||
|
recurrenceType === option.value && styles.recurrenceButtonTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{showStartPicker && (
|
||||||
|
<DateTimePicker
|
||||||
|
value={startDate}
|
||||||
|
mode="datetime"
|
||||||
|
onChange={(event, date) => {
|
||||||
|
setShowStartPicker(false);
|
||||||
|
if (date) setStartDate(date);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEndPicker && (
|
||||||
|
<DateTimePicker
|
||||||
|
value={endDate || new Date()}
|
||||||
|
mode="datetime"
|
||||||
|
onChange={(event, date) => {
|
||||||
|
setShowEndPicker(false);
|
||||||
|
if (date) setEndDate(date);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRemindPicker && (
|
||||||
|
<DateTimePicker
|
||||||
|
value={remindDate}
|
||||||
|
mode="datetime"
|
||||||
|
onChange={(event, date) => {
|
||||||
|
setShowRemindPicker(false);
|
||||||
|
if (date) setRemindDate(date);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
borderTopLeftRadius: theme.borderRadius.xl,
|
||||||
|
borderTopRightRadius: theme.borderRadius.xl,
|
||||||
|
maxHeight: '90%',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
marginBottom: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
height: 100,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
dateButton: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dateButtonText: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
marginLeft: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
switchField: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
recurrenceOptions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
recurrenceButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing.sm,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
recurrenceButtonActive: {
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
backgroundColor: theme.colors.surfaceVariant,
|
||||||
|
},
|
||||||
|
recurrenceButtonText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
recurrenceButtonTextActive: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "app-calendar-reminder",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "node_modules/expo/AppEntry.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-community/datetimepicker": "8.4.4",
|
||||||
|
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||||
|
"@react-navigation/native": "^6.1.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"expo": "~54.0.0",
|
||||||
|
"expo-notifications": "~0.32.17",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-worklets": "^0.10.0",
|
||||||
|
"react-native-worklets-core": "^1.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.0",
|
||||||
|
"@types/react": "~19.1.10",
|
||||||
|
"@types/react-native": "~0.73.0",
|
||||||
|
"babel-preset-expo": "~54.0.10",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
import { Event } from '../types';
|
||||||
|
import { eventApi } from '../api';
|
||||||
|
import EventModal from '../components/EventModal';
|
||||||
|
import EventCard from '../components/EventCard';
|
||||||
|
import { shouldShowEventOnDate, safeParseDate } from '../utils/recurrence';
|
||||||
|
|
||||||
|
const showError = (title: string, error: any) => {
|
||||||
|
const message = error instanceof Error ? error.message : '加载事件失败';
|
||||||
|
Alert.alert(title, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CalendarScreen() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||||
|
|
||||||
|
const loadEvents = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const allEvents: Event[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
// 加载前 6 个月 + 当前月 + 后 1 个月的数据,确保覆盖所有持续事件
|
||||||
|
for (let i = -6; i <= 1; i++) {
|
||||||
|
let targetDate = new Date(currentDate);
|
||||||
|
targetDate.setMonth(targetDate.getMonth() + i);
|
||||||
|
const year = targetDate.getFullYear();
|
||||||
|
const month = targetDate.getMonth() + 1;
|
||||||
|
|
||||||
|
const monthEvents = await eventApi.getEvents(year, month);
|
||||||
|
for (const event of monthEvents) {
|
||||||
|
if (!seenIds.has(event.id)) {
|
||||||
|
seenIds.add(event.id);
|
||||||
|
allEvents.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents(allEvents.filter(e => !e.deleted));
|
||||||
|
} catch (error) {
|
||||||
|
showError('加载失败', error);
|
||||||
|
}
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, [loadEvents])
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDaysInMonth = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// 添加上个月的日期
|
||||||
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||||
|
for (let i = firstDay.getDay() - 1; i >= 0; i--) {
|
||||||
|
const prevMonthDay = new Date(year, month - 1, prevMonthLastDay - i);
|
||||||
|
days.push({ date: prevMonthDay, isCurrentMonth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加当前月的日期
|
||||||
|
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||||
|
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加下个月的日期
|
||||||
|
const remainingDays = 42 - days.length;
|
||||||
|
for (let i = 1; i <= remainingDays; i++) {
|
||||||
|
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasEventOnDate = (date: Date) => {
|
||||||
|
return events.some(event => {
|
||||||
|
if (event.deleted) return false;
|
||||||
|
return shouldShowEventOnDate(event, date);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (date: Date) => {
|
||||||
|
if (!selectedDate) return false;
|
||||||
|
return date.toDateString() === selectedDate.toDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventsForSelectedDate = () => {
|
||||||
|
if (!selectedDate) return [];
|
||||||
|
return events
|
||||||
|
.filter(event => {
|
||||||
|
if (event.deleted) return false;
|
||||||
|
return shouldShowEventOnDate(event, selectedDate);
|
||||||
|
})
|
||||||
|
.sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousMonth = () => {
|
||||||
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
||||||
|
setSelectedDate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
||||||
|
setSelectedDate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddEvent = () => {
|
||||||
|
setSelectedEvent(null);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditEvent = (event: Event) => {
|
||||||
|
setSelectedEvent(event);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleComplete = async (event: Event) => {
|
||||||
|
try {
|
||||||
|
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||||
|
await loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
showError('更新失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEvent = async (event: Event) => {
|
||||||
|
Alert.alert(
|
||||||
|
'确认删除',
|
||||||
|
'确定要删除这个事件吗?',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '删除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await eventApi.deleteEvent(event.id);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
showError('删除失败', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
||||||
|
try {
|
||||||
|
if (selectedEvent) {
|
||||||
|
await eventApi.updateEvent(selectedEvent.id, eventData);
|
||||||
|
} else {
|
||||||
|
await eventApi.createEvent({
|
||||||
|
...eventData,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
deleted: false,
|
||||||
|
} as Event);
|
||||||
|
}
|
||||||
|
setModalVisible(false);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
showError('保存失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const days = getDaysInMonth(currentDate);
|
||||||
|
const selectedDateEvents = getEventsForSelectedDate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={previousMonth}>
|
||||||
|
<Ionicons name="chevron-back" size={28} color={theme.colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.monthTitle}>
|
||||||
|
{currentDate.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={nextMonth}>
|
||||||
|
<Ionicons name="chevron-forward" size={28} color={theme.colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.weekdays}>
|
||||||
|
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
|
||||||
|
<Text key={index} style={styles.weekday}>
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.calendarGrid}>
|
||||||
|
{days.map(({ date, isCurrentMonth }, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.dayCell,
|
||||||
|
isSelected(date) && styles.selectedDay,
|
||||||
|
isToday(date) && styles.today,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedDate(date)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dayText,
|
||||||
|
!isCurrentMonth && styles.otherMonthText,
|
||||||
|
isSelected(date) && styles.selectedDayText,
|
||||||
|
isToday(date) && styles.todayText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</Text>
|
||||||
|
{hasEventOnDate(date) && <View style={styles.eventDot} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedDate && (
|
||||||
|
<>
|
||||||
|
<View style={styles.eventsHeader}>
|
||||||
|
<Text style={styles.eventsHeaderTitle}>
|
||||||
|
{selectedDate.toLocaleDateString('zh-CN', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'long',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={handleAddEvent}>
|
||||||
|
<Ionicons name="add-circle" size={28} color={theme.colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={selectedDateEvents}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
style={styles.eventsListContainer}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<EventCard
|
||||||
|
event={item}
|
||||||
|
onEdit={handleEditEvent}
|
||||||
|
onToggleComplete={handleToggleComplete}
|
||||||
|
onDelete={handleDeleteEvent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.eventsList}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>这一天没有事件</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedDate && (
|
||||||
|
<View style={styles.noDateSelected}>
|
||||||
|
<Text style={styles.noDateText}>点击日期查看事项</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EventModal
|
||||||
|
visible={modalVisible}
|
||||||
|
event={selectedEvent}
|
||||||
|
onClose={() => setModalVisible(false)}
|
||||||
|
onSave={handleSaveEvent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
monthTitle: {
|
||||||
|
...theme.typography.h2,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
weekdays: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
weekday: {
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
...theme.typography.caption,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
calendarGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
dayCell: {
|
||||||
|
width: '14.28%',
|
||||||
|
aspectRatio: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
selectedDay: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
today: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
},
|
||||||
|
dayText: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
otherMonthText: {
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
selectedDayText: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
todayText: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
eventDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
eventsHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
eventsHeaderTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
eventsListContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
eventsList: {
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
padding: theme.spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
noDateSelected: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
noDateText: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
import { Event } from '../types';
|
||||||
|
import { eventApi } from '../api';
|
||||||
|
import EventModal from '../components/EventModal';
|
||||||
|
import EventCard from '../components/EventCard';
|
||||||
|
import { shouldShowEventOnDate, safeParseDate } from '../utils/recurrence';
|
||||||
|
|
||||||
|
const showError = (title: string, error: any) => {
|
||||||
|
const message = error instanceof Error ? error.message : '加载事件失败';
|
||||||
|
Alert.alert(title, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TodayScreen() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||||
|
|
||||||
|
const loadEvents = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const today = new Date();
|
||||||
|
const allEvents = await eventApi.getEvents(today.getFullYear(), today.getMonth() + 1);
|
||||||
|
const todayEvents = allEvents.filter(event => {
|
||||||
|
if (event.deleted) return false;
|
||||||
|
return shouldShowEventOnDate(event, today);
|
||||||
|
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||||
|
setEvents(todayEvents);
|
||||||
|
} catch (error) {
|
||||||
|
showError('加载失败', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, [loadEvents])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddEvent = () => {
|
||||||
|
setSelectedEvent(null);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditEvent = (event: Event) => {
|
||||||
|
setSelectedEvent(event);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleComplete = async (event: Event) => {
|
||||||
|
try {
|
||||||
|
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||||
|
await loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
showError('更新失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEvent = async (event: Event) => {
|
||||||
|
Alert.alert(
|
||||||
|
'确认删除',
|
||||||
|
'确定要删除这个事件吗?',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '删除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await eventApi.deleteEvent(event.id);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
showError('删除失败', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
||||||
|
try {
|
||||||
|
if (selectedEvent) {
|
||||||
|
await eventApi.updateEvent(selectedEvent.id, eventData);
|
||||||
|
} else {
|
||||||
|
await eventApi.createEvent({
|
||||||
|
...eventData,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
deleted: false,
|
||||||
|
} as Event);
|
||||||
|
}
|
||||||
|
setModalVisible(false);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
showError('保存失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGreeting = () => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 12) return '早上好';
|
||||||
|
if (hour < 18) return '下午好';
|
||||||
|
return '晚上好';
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingEvents = events.filter(e => !e.completed);
|
||||||
|
const completedEvents = events.filter(e => e.completed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<Text style={styles.greeting}>{getGreeting()} 👋</Text>
|
||||||
|
<Text style={styles.date}>
|
||||||
|
{new Date().toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.addButton} onPress={handleAddEvent}>
|
||||||
|
<Ionicons name="add" size={28} color={theme.colors.background} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>{pendingEvents.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>待完成</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>{completedEvents.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>已完成</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>{events.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>共计</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>待完成</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={pendingEvents}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<EventCard
|
||||||
|
event={item}
|
||||||
|
onEdit={handleEditEvent}
|
||||||
|
onToggleComplete={handleToggleComplete}
|
||||||
|
onDelete={handleDeleteEvent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>今天没有待办事项 🎉</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EventModal
|
||||||
|
visible={modalVisible}
|
||||||
|
event={selectedEvent}
|
||||||
|
onClose={() => setModalVisible(false)}
|
||||||
|
onSave={handleSaveEvent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
...theme.typography.h1,
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
marginBottom: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
statCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statNumber: {
|
||||||
|
...theme.typography.h2,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
padding: theme.spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { TextStyle } from 'react-native';
|
||||||
|
|
||||||
|
interface Typography {
|
||||||
|
[key: string]: TextStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = {
|
||||||
|
colors: {
|
||||||
|
background: '#0a0a14',
|
||||||
|
surface: '#161624',
|
||||||
|
surfaceVariant: '#1e1e30',
|
||||||
|
primary: '#e8b86d',
|
||||||
|
primaryVariant: '#d4a45c',
|
||||||
|
text: '#ffffff',
|
||||||
|
textSecondary: '#9999aa',
|
||||||
|
border: '#2a2a40',
|
||||||
|
success: '#4ade80',
|
||||||
|
warning: '#fbbf24',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 24,
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
h1: { fontSize: 28, fontWeight: 'bold' } as TextStyle,
|
||||||
|
h2: { fontSize: 24, fontWeight: 'bold' } as TextStyle,
|
||||||
|
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
|
||||||
|
body: { fontSize: 16, fontWeight: 'normal' } as TextStyle,
|
||||||
|
bodySmall: { fontSize: 14, fontWeight: 'normal' } as TextStyle,
|
||||||
|
caption: { fontSize: 12, fontWeight: 'normal' } as TextStyle,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
start: string;
|
||||||
|
end: string | null;
|
||||||
|
allDay: boolean;
|
||||||
|
remindAt: string | null;
|
||||||
|
recurrence: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completed: boolean;
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
ok: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
export interface Recurrence {
|
||||||
|
type: 'NONE' | 'DAILY' | 'WEEKLY';
|
||||||
|
interval: number;
|
||||||
|
by: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventForReminder {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end?: string | null;
|
||||||
|
remindAt?: string | null;
|
||||||
|
recurrence?: string | null;
|
||||||
|
completed?: boolean;
|
||||||
|
deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecurrenceStr(str: string | null): Recurrence {
|
||||||
|
const r = (str || '').trim();
|
||||||
|
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||||
|
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||||
|
const mInt = /interval=(\d+)/.exec(r);
|
||||||
|
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||||
|
const mBy = /by=([0-9,]+)/.exec(r);
|
||||||
|
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n >= 1 && n <= 7) : [];
|
||||||
|
return { type, interval, by };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTimeFromIso(dateObj: Date, timeSourceIso: string): string {
|
||||||
|
const d = new Date(dateObj.getTime());
|
||||||
|
const t = timeSourceIso ? safeParseDate(timeSourceIso) : null;
|
||||||
|
const hh = t ? t.getHours() : 0;
|
||||||
|
const mm = t ? t.getMinutes() : 0;
|
||||||
|
d.setHours(hh, mm, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDateStr(dateStr: string): string {
|
||||||
|
// 支持两种格式:2026-06-28T21:30:00 和 2026-06-28 21:30
|
||||||
|
if (dateStr.includes('T')) {
|
||||||
|
return dateStr.split('T')[0];
|
||||||
|
}
|
||||||
|
if (dateStr.includes(' ')) {
|
||||||
|
return dateStr.split(' ')[0];
|
||||||
|
}
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeParseDate(dateStr: string): Date {
|
||||||
|
// 尝试安全地解析日期,支持多种格式
|
||||||
|
try {
|
||||||
|
// 先尝试直接解析
|
||||||
|
let date = new Date(dateStr);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是 2026-06-28 21:30 格式,尝试替换空格为 T
|
||||||
|
if (dateStr.includes(' ')) {
|
||||||
|
try {
|
||||||
|
let date = new Date(dateStr.replace(' ', 'T'));
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后尝试用 Date.UTC 解析
|
||||||
|
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
const year = parseInt(match[1], 10);
|
||||||
|
const month = parseInt(match[2], 10) - 1;
|
||||||
|
const day = parseInt(match[3], 10);
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowEventOnDate(
|
||||||
|
event: { start: string; recurrence: string | null; end?: string | null },
|
||||||
|
targetDate: Date
|
||||||
|
): boolean {
|
||||||
|
const eventDate = extractDateStr(event.start);
|
||||||
|
const targetDateStr = targetDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// 1. 普通事件 - 开始日期就是当天
|
||||||
|
if (eventDate === targetDateStr) return true;
|
||||||
|
|
||||||
|
const evStart = safeParseDate(event.start);
|
||||||
|
const targetDayStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||||
|
const eventStartDay = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate());
|
||||||
|
|
||||||
|
// 2. 持续多天的事件 - 目标日期在 [start, end] 之间
|
||||||
|
if (event.end) {
|
||||||
|
const evEnd = safeParseDate(event.end);
|
||||||
|
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||||
|
|
||||||
|
// 如果目标日期在事件日期范围内,就显示
|
||||||
|
if (targetDayStart >= eventStartDay && targetDayStart <= eventEndDay) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 循环事件
|
||||||
|
const rec = parseRecurrenceStr(event.recurrence);
|
||||||
|
if (rec.type === 'NONE') return false;
|
||||||
|
|
||||||
|
// 如果目标日期在开始日期之前,不显示
|
||||||
|
if (targetDayStart < eventStartDay) return false;
|
||||||
|
|
||||||
|
// 如果有结束日期,并且目标日期在结束日期之后,不显示
|
||||||
|
if (event.end) {
|
||||||
|
const evEnd = safeParseDate(event.end);
|
||||||
|
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||||
|
if (targetDayStart > eventEndDay) return false;
|
||||||
|
}
|
||||||
|
// 没有结束日期的话,就永远显示
|
||||||
|
|
||||||
|
if (rec.type === 'DAILY') {
|
||||||
|
const diffDays = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (24 * 3600 * 1000));
|
||||||
|
return diffDays % rec.interval === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rec.type === 'WEEKLY') {
|
||||||
|
const weeksSinceStart = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (7 * 24 * 3600 * 1000));
|
||||||
|
const weekday = targetDayStart.getDay() === 0 ? 7 : targetDayStart.getDay();
|
||||||
|
const by = rec.by.length ? rec.by : [(safeParseDate(event.start).getDay() || 7)];
|
||||||
|
return by.includes(weekday) && (weeksSinceStart % rec.interval === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeUpcomingReminders(
|
||||||
|
e: EventForReminder,
|
||||||
|
sinceMs: number,
|
||||||
|
untilMs: number
|
||||||
|
): string[] {
|
||||||
|
const rec = parseRecurrenceStr(e.recurrence || null);
|
||||||
|
const evStart = safeParseDate(e.start);
|
||||||
|
const reminders: string[] = [];
|
||||||
|
|
||||||
|
if (rec.type === 'NONE') {
|
||||||
|
const iso = e.remindAt || e.start;
|
||||||
|
if (!iso) return reminders;
|
||||||
|
const t = safeParseDate(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDayMs = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate()).getTime();
|
||||||
|
const windowStartDayMs = new Date(
|
||||||
|
new Date(sinceMs).getFullYear(),
|
||||||
|
new Date(sinceMs).getMonth(),
|
||||||
|
new Date(sinceMs).getDate()
|
||||||
|
).getTime();
|
||||||
|
const windowEndDayMs = new Date(
|
||||||
|
new Date(untilMs).getFullYear(),
|
||||||
|
new Date(untilMs).getMonth(),
|
||||||
|
new Date(untilMs).getDate()
|
||||||
|
).getTime();
|
||||||
|
|
||||||
|
let endDayMs: number;
|
||||||
|
if (e.end) {
|
||||||
|
const evEnd = safeParseDate(e.end);
|
||||||
|
endDayMs = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate()).getTime();
|
||||||
|
} else {
|
||||||
|
// 无结束日期时,使用 untilMs 作为结束边界,避免提前终止
|
||||||
|
endDayMs = windowEndDayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDay = Math.max(startDayMs, windowStartDayMs);
|
||||||
|
const toDay = Math.min(endDayMs, windowEndDayMs);
|
||||||
|
const timeSourceIso = e.remindAt || e.start;
|
||||||
|
|
||||||
|
for (let d = fromDay; d <= toDay; d += 24 * 3600 * 1000) {
|
||||||
|
const cursor = new Date(d);
|
||||||
|
if (rec.type === 'DAILY') {
|
||||||
|
const diffDays = Math.floor((d - startDayMs) / (24 * 3600 * 1000));
|
||||||
|
if (diffDays % rec.interval === 0) {
|
||||||
|
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||||
|
const t = safeParseDate(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
}
|
||||||
|
} else if (rec.type === 'WEEKLY') {
|
||||||
|
const weekday = cursor.getDay() === 0 ? 7 : cursor.getDay();
|
||||||
|
const weeksSinceStart = Math.floor((d - startDayMs) / (7 * 24 * 3600 * 1000));
|
||||||
|
const by = rec.by.length ? rec.by : [(safeParseDate(e.start).getDay() || 7)];
|
||||||
|
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||||
|
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||||
|
const t = safeParseDate(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { orderBoxApi } from './api';
|
||||||
|
import { theme } from './theme';
|
||||||
|
import { OrderBoxState } from './types';
|
||||||
|
import { CardsScreen, HistoryScreen } from './kits/motion_reminder';
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
type ScreenMode = 'hub' | 'motion_reminder';
|
||||||
|
|
||||||
|
const HubScreen = ({
|
||||||
|
onEnterMotionReminder,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
onEnterMotionReminder: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) => (
|
||||||
|
<View style={styles.hubContainer}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor={theme.colors.background} />
|
||||||
|
<View style={styles.hubHeader}>
|
||||||
|
<Text style={styles.hubTitle}>框子</Text>
|
||||||
|
<Text style={styles.hubSubtitle}>kit 聚合壳。当前阶段先落地第一个工具:运动提醒宝。</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.kitCard} disabled={loading} onPress={onEnterMotionReminder}>
|
||||||
|
<View style={styles.kitCardIcon}>
|
||||||
|
<Ionicons name="barbell-outline" size={28} color={theme.colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.kitCardBody}>
|
||||||
|
<Text style={styles.kitCardTitle}>运动提醒宝</Text>
|
||||||
|
<Text style={styles.kitCardText}>流程卡片、自动切步、语音播报、大字倒计时、下一个动作常驻显示。</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={24} color={theme.colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
function MotionReminderKit({ onExit }: { onExit: () => void }) {
|
||||||
|
const [serverState, setServerState] = useState<OrderBoxState | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refreshData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const nextState = await orderBoxApi.getState();
|
||||||
|
setServerState(nextState);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '加载失败';
|
||||||
|
Alert.alert('加载失败', message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshData();
|
||||||
|
}, [refreshData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContainer independent>
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: theme.colors.background },
|
||||||
|
headerTintColor: theme.colors.text,
|
||||||
|
tabBarStyle: { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border },
|
||||||
|
tabBarActiveTintColor: theme.colors.primary,
|
||||||
|
tabBarInactiveTintColor: theme.colors.textSecondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Cards"
|
||||||
|
children={() => <CardsScreen onExit={onExit} serverState={serverState} onRefresh={refreshData} />}
|
||||||
|
options={{
|
||||||
|
title: '运动提醒宝',
|
||||||
|
tabBarLabel: '卡片',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="albums-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="History"
|
||||||
|
children={() => <HistoryScreen onRefresh={refreshData} />}
|
||||||
|
options={{
|
||||||
|
title: '执行历史',
|
||||||
|
tabBarLabel: '历史',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="time-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [screenMode, setScreenMode] = useState<ScreenMode>('hub');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const initialize = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await orderBoxApi.getMeta();
|
||||||
|
const state = await orderBoxApi.getState();
|
||||||
|
if (state.activeKit === 'motion_reminder') {
|
||||||
|
setScreenMode('motion_reminder');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '初始化失败';
|
||||||
|
Alert.alert('初始化失败', message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void initialize();
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
const enterMotionReminder = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await orderBoxApi.setActiveKit('motion_reminder');
|
||||||
|
setScreenMode('motion_reminder');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '进入失败';
|
||||||
|
Alert.alert('进入失败', message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (screenMode === 'motion_reminder') {
|
||||||
|
return <MotionReminderKit onExit={() => setScreenMode('hub')} />;
|
||||||
|
}
|
||||||
|
return <HubScreen loading={loading} onEnterMotionReminder={() => void enterMotionReminder()} />;
|
||||||
|
}, [loading, screenMode]);
|
||||||
|
|
||||||
|
return <View style={styles.appContainer}>{content}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
appContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
hubContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
padding: theme.spacing.lg,
|
||||||
|
paddingTop: 72,
|
||||||
|
},
|
||||||
|
hubHeader: {
|
||||||
|
marginBottom: theme.spacing.xl,
|
||||||
|
},
|
||||||
|
hubTitle: {
|
||||||
|
...theme.typography.h1,
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
hubSubtitle: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
kitCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
padding: theme.spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
},
|
||||||
|
kitCardIcon: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: theme.colors.primarySoft,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
kitCardBody: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
kitCardTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
kitCardText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
ApiEnvelope,
|
||||||
|
MotionCard,
|
||||||
|
MotionSession,
|
||||||
|
OrderBoxKitId,
|
||||||
|
OrderBoxMeta,
|
||||||
|
OrderBoxState,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_order_box';
|
||||||
|
const MOTION_BASE = `${API_BASE_URL}/kits/motion_reminder`;
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatError = (error: any): string => {
|
||||||
|
if (error?.response) {
|
||||||
|
return `服务器响应 ${error.response.status}: ${error.response.statusText}\n${JSON.stringify(error.response.data)}`;
|
||||||
|
}
|
||||||
|
if (error?.request) {
|
||||||
|
return `服务器无响应: 无法连接到 ${API_BASE_URL}`;
|
||||||
|
}
|
||||||
|
if (error?.message) {
|
||||||
|
return `请求错误: ${error.message}`;
|
||||||
|
}
|
||||||
|
return '未知错误';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const orderBoxApi = {
|
||||||
|
async getMeta(): Promise<OrderBoxMeta> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<OrderBoxMeta>('/meta');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getState(): Promise<OrderBoxState> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<OrderBoxState>('/state');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setActiveKit(kitId: OrderBoxKitId | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.put('/state/active-kit', { kitId });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetRunningState(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.post('/state/reset-running');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const motionReminderApi = {
|
||||||
|
async listCards(): Promise<MotionCard[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<ApiEnvelope<MotionCard[]>>(`${MOTION_BASE}/cards`);
|
||||||
|
return response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCard(card: Partial<MotionCard>): Promise<MotionCard> {
|
||||||
|
try {
|
||||||
|
const response = card.id
|
||||||
|
? await api.put<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card)
|
||||||
|
: await api.post<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card);
|
||||||
|
if (!response.data.ok || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || '保存失败');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCard(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.delete(`${MOTION_BASE}/cards`, { params: { id } });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async listHistory(): Promise<MotionSession[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<ApiEnvelope<MotionSession[]>>(`${MOTION_BASE}/history`);
|
||||||
|
return response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startSession(card: MotionCard): Promise<MotionSession> {
|
||||||
|
try {
|
||||||
|
const response = await api.post<ApiEnvelope<MotionSession>>(`${MOTION_BASE}/sessions/start`, { card });
|
||||||
|
if (!response.data.ok || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || '启动失败');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncSession(payload: Partial<MotionSession> & { id: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.post(`${MOTION_BASE}/sessions/sync`, payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async finishSession(payload: Partial<MotionSession> & { id: string; status: 'completed' | 'stopped' }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.post(`${MOTION_BASE}/sessions/finish`, payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "框子",
|
||||||
|
"slug": "order-box",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#080b12"
|
||||||
|
},
|
||||||
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#080b12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-splash-screen"
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "e11fbd29-d9e4-41d5-a2d4-a563af8dbb54"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function(api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { ApiEnvelope, MotionCard, MotionSession } from './types';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_order_box';
|
||||||
|
const MOTION_BASE = `${API_BASE_URL}/kits/motion_reminder`;
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatError = (error: any): string => {
|
||||||
|
if (error?.response) {
|
||||||
|
return `服务器响应 ${error.response.status}: ${error.response.statusText}\n${JSON.stringify(error.response.data)}`;
|
||||||
|
}
|
||||||
|
if (error?.request) {
|
||||||
|
return `服务器无响应: 无法连接到 ${API_BASE_URL}`;
|
||||||
|
}
|
||||||
|
if (error?.message) {
|
||||||
|
return `请求错误: ${error.message}`;
|
||||||
|
}
|
||||||
|
return '未知错误';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const motionReminderApi = {
|
||||||
|
async listCards(): Promise<MotionCard[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<ApiEnvelope<MotionCard[]>>(`${MOTION_BASE}/cards`);
|
||||||
|
return response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCard(card: Partial<MotionCard>): Promise<MotionCard> {
|
||||||
|
try {
|
||||||
|
const response = card.id
|
||||||
|
? await api.put<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card)
|
||||||
|
: await api.post<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card);
|
||||||
|
if (!response.data.ok || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || '保存失败');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCard(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.delete(`${MOTION_BASE}/cards`, { params: { id } });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async listHistory(): Promise<MotionSession[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<ApiEnvelope<MotionSession[]>>(`${MOTION_BASE}/history`);
|
||||||
|
return response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startSession(card: MotionCard): Promise<MotionSession> {
|
||||||
|
try {
|
||||||
|
const response = await api.post<ApiEnvelope<MotionSession>>(`${MOTION_BASE}/sessions/start`, { card });
|
||||||
|
if (!response.data.ok || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || '启动失败');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncSession(payload: Partial<MotionSession> & { id: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.post(`${MOTION_BASE}/sessions/sync`, payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async finishSession(payload: Partial<MotionSession> & { id: string; status: 'completed' | 'stopped' }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.post(`${MOTION_BASE}/sessions/finish`, payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
PanResponder,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { motionReminderApi } from '../api';
|
||||||
|
import { theme } from '../../../theme';
|
||||||
|
import { LoopMode, MotionCard, MotionStep, MotionStepType } from '../types';
|
||||||
|
|
||||||
|
interface CardEditorProps {
|
||||||
|
visible: boolean;
|
||||||
|
initialCard: MotionCard | null;
|
||||||
|
onSave: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createId = () => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
const createDefaultStep = (type: MotionStepType = 'action'): MotionStep => ({
|
||||||
|
id: createId(),
|
||||||
|
type,
|
||||||
|
name: type === 'rest' ? '休息' : '',
|
||||||
|
durationSec: type === 'rest' ? 30 : 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDraftCard = (): MotionCard => ({
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
steps: [createDefaultStep('action'), createDefaultStep('rest')],
|
||||||
|
loopMode: 'repeat_count',
|
||||||
|
loopCount: 3,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||||
|
const mm = Math.floor(safe / 60);
|
||||||
|
const ss = safe % 60;
|
||||||
|
if (mm > 0) return `${mm}分${ss}秒`;
|
||||||
|
return `${ss}秒`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Draggable Step Item ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DraggableStepProps {
|
||||||
|
step: MotionStep;
|
||||||
|
index: number;
|
||||||
|
totalSteps: number;
|
||||||
|
canRemove: boolean;
|
||||||
|
onUpdate: (updates: Partial<MotionStep>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
onDragStart: () => void;
|
||||||
|
onDragMove: (dy: number) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraggableStepItem: React.FC<DraggableStepProps> = ({
|
||||||
|
step, index, totalSteps, canRemove,
|
||||||
|
onUpdate, onRemove, onCopy, onDragStart, onDragMove, onDragEnd,
|
||||||
|
}) => {
|
||||||
|
const isAction = step.type === 'action';
|
||||||
|
const borderColor = isAction ? theme.colors.primary : theme.colors.success;
|
||||||
|
const backgroundColor = isAction ? theme.colors.primarySoft : theme.colors.surfaceStrong;
|
||||||
|
|
||||||
|
const panResponder = PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onPanResponderGrant: () => onDragStart(),
|
||||||
|
onPanResponderMove: (_, gestureState) => onDragMove(gestureState.dy),
|
||||||
|
onPanResponderRelease: () => onDragEnd(),
|
||||||
|
onPanResponderTerminate: () => onDragEnd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.stepEditor, { borderColor, backgroundColor }]}>
|
||||||
|
<View {...panResponder.panHandlers} style={styles.stepDragHandle}>
|
||||||
|
<Ionicons name="reorder-three-outline" size={20} color={theme.colors.textSecondary} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.stepEditorHeader}>
|
||||||
|
<View style={styles.stepTitleRow}>
|
||||||
|
<Text style={[styles.stepBadge, { backgroundColor: borderColor }]}>
|
||||||
|
{isAction ? '动作' : '休息'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.stepEditorTitle}>步骤 {index + 1}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepActions}>
|
||||||
|
<TouchableOpacity onPress={onCopy}>
|
||||||
|
<Ionicons name="copy-outline" size={18} color={theme.colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity disabled={!canRemove} onPress={onRemove}>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={canRemove ? theme.colors.error : theme.colors.textDisabled} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={step.name}
|
||||||
|
onChangeText={(name) => onUpdate({ name })}
|
||||||
|
placeholder={isAction ? '例如:俯卧撑' : '例如:休息'}
|
||||||
|
placeholderTextColor={theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={String(step.durationSec || '')}
|
||||||
|
keyboardType="numeric"
|
||||||
|
onChangeText={(value) => {
|
||||||
|
const numericValue = value.replace(/[^0-9]/g, '');
|
||||||
|
onUpdate({ durationSec: numericValue === '' ? null : Number(numericValue) });
|
||||||
|
}}
|
||||||
|
placeholder="时长(秒)"
|
||||||
|
placeholderTextColor={theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.stepDurationHint}>时长:{formatDuration(step.durationSec || 0)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Card Editor ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CardEditor: React.FC<CardEditorProps> = ({ visible, initialCard, onSave, onClose }) => {
|
||||||
|
const [draftCard, setDraftCard] = useState<MotionCard>(initialCard || createDraftCard());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [dragOffset, setDragOffset] = useState(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setDraftCard(initialCard || createDraftCard());
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOffset(0);
|
||||||
|
}
|
||||||
|
}, [visible, initialCard]);
|
||||||
|
|
||||||
|
const totalDuration = draftCard.steps.reduce((sum, step) => sum + Math.max(1, Number(step.durationSec || 0)), 0);
|
||||||
|
const loops = draftCard.loopMode === 'repeat_count'
|
||||||
|
? Math.max(1, Number(draftCard.loopCount || 1))
|
||||||
|
: null;
|
||||||
|
const globalDuration = loops !== null ? totalDuration * loops : null;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const title = draftCard.title.trim();
|
||||||
|
const cleanSteps = draftCard.steps
|
||||||
|
.map((step) => ({
|
||||||
|
...step,
|
||||||
|
name: step.name.trim(),
|
||||||
|
durationSec: Math.max(1, Math.floor(Number(step.durationSec || 0))),
|
||||||
|
}))
|
||||||
|
.filter((step) => step.name && step.durationSec > 0);
|
||||||
|
|
||||||
|
// 检查是否有步骤时长为空或无效
|
||||||
|
const invalidSteps = draftCard.steps.filter((s) => {
|
||||||
|
const val = Number(s.durationSec || 0);
|
||||||
|
return !val || val <= 0;
|
||||||
|
});
|
||||||
|
if (invalidSteps.length > 0) {
|
||||||
|
Alert.alert('无法保存', `${invalidSteps.length} 个步骤的时长无效,请输入大于 0 的数字`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
Alert.alert('无法保存', '请输入卡片名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cleanSteps.length) {
|
||||||
|
Alert.alert('无法保存', '至少保留一个有效步骤');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draftCard.loopMode === 'repeat_count' && Math.max(1, Number(draftCard.loopCount || 0)) < 1) {
|
||||||
|
Alert.alert('无法保存', '循环次数至少为 1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await motionReminderApi.saveCard({
|
||||||
|
...draftCard,
|
||||||
|
title,
|
||||||
|
steps: cleanSteps,
|
||||||
|
loopCount: draftCard.loopMode === 'repeat_count'
|
||||||
|
? Math.max(1, Number(draftCard.loopCount || 1))
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
onSave();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '保存失败';
|
||||||
|
Alert.alert('保存失败', message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStep = useCallback((stepId: string, updates: Partial<MotionStep>) => {
|
||||||
|
setDraftCard((prev) => ({
|
||||||
|
...prev,
|
||||||
|
steps: prev.steps.map((s) => (s.id === stepId ? { ...s, ...updates } : s)),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeStep = useCallback((stepId: string) => {
|
||||||
|
setDraftCard((prev) => ({
|
||||||
|
...prev,
|
||||||
|
steps: prev.steps.filter((s) => s.id !== stepId),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyStep = useCallback((stepId: string) => {
|
||||||
|
setDraftCard((prev) => {
|
||||||
|
const step = prev.steps.find((s) => s.id === stepId);
|
||||||
|
if (!step) return prev;
|
||||||
|
const newStep: MotionStep = {
|
||||||
|
...step,
|
||||||
|
id: createId(),
|
||||||
|
name: `${step.name} (副本)`,
|
||||||
|
};
|
||||||
|
const index = prev.steps.findIndex((s) => s.id === stepId);
|
||||||
|
const newSteps = [...prev.steps];
|
||||||
|
newSteps.splice(index + 1, 0, newStep);
|
||||||
|
return { ...prev, steps: newSteps };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addStep = useCallback((type: MotionStepType) => {
|
||||||
|
setDraftCard((prev) => ({ ...prev, steps: [...prev.steps, createDefaultStep(type)] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((index: number) => {
|
||||||
|
setDragIndex(index);
|
||||||
|
setDragOffset(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragMove = useCallback((dy: number) => {
|
||||||
|
setDragOffset(dy);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
const threshold = 60;
|
||||||
|
let newIndex = dragIndex;
|
||||||
|
if (dragOffset > threshold && dragIndex < draftCard.steps.length - 1) {
|
||||||
|
newIndex = dragIndex + 1;
|
||||||
|
} else if (dragOffset < -threshold && dragIndex > 0) {
|
||||||
|
newIndex = dragIndex - 1;
|
||||||
|
}
|
||||||
|
if (newIndex !== dragIndex) {
|
||||||
|
setDraftCard((prev) => {
|
||||||
|
const newSteps = [...prev.steps];
|
||||||
|
const [moved] = newSteps.splice(dragIndex, 1);
|
||||||
|
newSteps.splice(newIndex, 0, moved);
|
||||||
|
return { ...prev, steps: newSteps };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOffset(0);
|
||||||
|
}, [dragIndex, dragOffset, draftCard.steps.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<TouchableOpacity onPress={onClose}>
|
||||||
|
<Ionicons name="close" size={24} color={theme.colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.modalTitle}>{draftCard.id ? '编辑流程卡片' : '新建流程卡片'}</Text>
|
||||||
|
<TouchableOpacity disabled={saving} onPress={handleSave}>
|
||||||
|
<Text style={[styles.saveText, saving && styles.disabledText]}>{saving ? '保存中…' : '保存'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.modalBody} contentContainerStyle={styles.modalContent}>
|
||||||
|
<Text style={styles.inputLabel}>卡片名称</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={draftCard.title}
|
||||||
|
onChangeText={(title) => setDraftCard((prev) => ({ ...prev, title }))}
|
||||||
|
placeholder="例如:晨练循环"
|
||||||
|
placeholderTextColor={theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.inputLabel}>循环方式</Text>
|
||||||
|
<View style={styles.segmentRow}>
|
||||||
|
{[
|
||||||
|
{ label: '单次', value: 'once' as LoopMode },
|
||||||
|
{ label: 'N 次', value: 'repeat_count' as LoopMode },
|
||||||
|
{ label: '无限', value: 'repeat_forever' as LoopMode },
|
||||||
|
].map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.value}
|
||||||
|
style={[styles.segmentButton, draftCard.loopMode === item.value && styles.segmentButtonActive]}
|
||||||
|
onPress={() => setDraftCard((prev) => ({ ...prev, loopMode: item.value }))}
|
||||||
|
>
|
||||||
|
<Text style={[styles.segmentText, draftCard.loopMode === item.value && styles.segmentTextActive]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{draftCard.loopMode === 'repeat_count' ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.inputLabel}>循环次数</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={String(Math.max(1, Number(draftCard.loopCount || 1)))}
|
||||||
|
keyboardType="numeric"
|
||||||
|
onChangeText={(value) => {
|
||||||
|
const next = Math.max(1, Number(value || 1));
|
||||||
|
setDraftCard((prev) => ({ ...prev, loopCount: next }));
|
||||||
|
}}
|
||||||
|
placeholder="输入循环次数"
|
||||||
|
placeholderTextColor={theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.stepsHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>流程步骤</Text>
|
||||||
|
<View style={styles.stepsHeaderButtons}>
|
||||||
|
<TouchableOpacity style={styles.smallButton} onPress={() => addStep('action')}>
|
||||||
|
<Text style={styles.smallButtonText}>加动作</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.smallButton} onPress={() => addStep('rest')}>
|
||||||
|
<Text style={styles.smallButtonText}>加休息</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Total duration preview */}
|
||||||
|
<View style={styles.totalDurationCard}>
|
||||||
|
<Ionicons name="time-outline" size={18} color={theme.colors.primary} />
|
||||||
|
<Text style={styles.totalDurationText}>总时长:{formatDuration(totalDuration)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Steps list */}
|
||||||
|
{draftCard.steps.map((step, index) => (
|
||||||
|
<DraggableStepItem
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
index={index}
|
||||||
|
totalSteps={draftCard.steps.length}
|
||||||
|
canRemove={draftCard.steps.length > 1}
|
||||||
|
onUpdate={(updates) => updateStep(step.id, updates)}
|
||||||
|
onRemove={() => removeStep(step.id)}
|
||||||
|
onCopy={() => copyStep(step.id)}
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragMove={handleDragMove}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 总时长信息卡片 */}
|
||||||
|
<View style={styles.durationInfoCard}>
|
||||||
|
<View style={styles.durationInfoRow}>
|
||||||
|
<Ionicons name="timer-outline" size={18} color={theme.colors.textSecondary} />
|
||||||
|
<Text style={styles.durationInfoLabel}>单轮时长</Text>
|
||||||
|
<Text style={styles.durationInfoValue}>{formatDuration(totalDuration)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.durationInfoRow}>
|
||||||
|
<Ionicons name="repeat-outline" size={18} color={theme.colors.primary} />
|
||||||
|
<Text style={styles.durationInfoLabel}>全局总时长</Text>
|
||||||
|
<Text style={[styles.durationInfoValue, styles.durationInfoHighlight]}>
|
||||||
|
{draftCard.loopMode === 'repeat_forever'
|
||||||
|
? `无限循环(单轮 ${formatDuration(totalDuration)})`
|
||||||
|
: formatDuration(globalDuration || 0)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.58)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
modalContainer: {
|
||||||
|
maxHeight: '92%',
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
borderTopLeftRadius: theme.borderRadius.xl,
|
||||||
|
borderTopRightRadius: theme.borderRadius.xl,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
saveText: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
disabledText: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
modalBody: {
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
paddingBottom: theme.spacing.xl,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginTop: theme.spacing.lg,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
color: theme.colors.text,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
segmentRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
segmentButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
},
|
||||||
|
segmentButtonActive: {
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
backgroundColor: theme.colors.primarySoft,
|
||||||
|
},
|
||||||
|
segmentText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
segmentTextActive: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
stepsHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: theme.spacing.xl,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
stepsHeaderButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
smallButton: {
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: theme.colors.surfaceStrong,
|
||||||
|
},
|
||||||
|
smallButtonText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
totalDurationCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
backgroundColor: theme.colors.primarySoft,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
totalDurationText: {
|
||||||
|
...theme.typography.bodyLarge,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
durationInfoCard: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
durationInfoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
durationInfoLabel: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
durationInfoValue: {
|
||||||
|
...theme.typography.bodyLarge,
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
durationInfoHighlight: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
stepEditor: {
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 2,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
stepDragHandle: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
stepEditorHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
stepTitleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
stepBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
stepEditorTitle: {
|
||||||
|
...theme.typography.bodyLarge,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
stepActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
stepDurationHint: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
marginTop: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useKeepAwake } from 'expo-keep-awake';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { UseRunnerReturn } from '../hooks/useRunner';
|
||||||
|
import { saveRunnerCache, clearRunnerCache } from '../hooks/useMotionAppState';
|
||||||
|
import { theme } from '../../../theme';
|
||||||
|
|
||||||
|
interface RunnerModalProps {
|
||||||
|
runner: UseRunnerReturn;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||||
|
const mm = Math.floor(safe / 60);
|
||||||
|
const ss = safe % 60;
|
||||||
|
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RunnerModal: React.FC<RunnerModalProps> = ({ runner, onClose }) => {
|
||||||
|
const { runner: runnerState, pauseSession, resumeSession, skipStep, finishSession } = runner;
|
||||||
|
|
||||||
|
// 保持屏幕常亮
|
||||||
|
useKeepAwake();
|
||||||
|
|
||||||
|
// 进入后台时保存状态
|
||||||
|
const handleBackground = useCallback(async () => {
|
||||||
|
if (!runnerState || runnerState.status !== 'running') return;
|
||||||
|
await saveRunnerCache({
|
||||||
|
sessionId: runnerState.sessionId,
|
||||||
|
currentLoop: runnerState.currentLoop,
|
||||||
|
completedLoops: runnerState.completedLoops,
|
||||||
|
currentStepIndex: runnerState.currentStepIndex,
|
||||||
|
remainingSec: runnerState.remainingSec,
|
||||||
|
status: runnerState.status,
|
||||||
|
pausedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await pauseSession();
|
||||||
|
}, [runnerState, pauseSession]);
|
||||||
|
|
||||||
|
// 回到前台时恢复
|
||||||
|
const handleForeground = useCallback(async () => {
|
||||||
|
// 由外部 CardsScreen 处理恢复逻辑
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).__motionRunnerBackground = handleBackground;
|
||||||
|
(window as any).__motionRunnerForeground = handleForeground;
|
||||||
|
return () => {
|
||||||
|
delete (window as any).__motionRunnerBackground;
|
||||||
|
delete (window as any).__motionRunnerForeground;
|
||||||
|
};
|
||||||
|
}, [handleBackground, handleForeground]);
|
||||||
|
|
||||||
|
const currentStep = runnerState ? runnerState.card.steps[runnerState.currentStepIndex] : null;
|
||||||
|
const nextStep = runnerState ? runnerState.card.steps[runnerState.currentStepIndex + 1] || null : null;
|
||||||
|
const totalSteps = runnerState?.card.steps.length || 0;
|
||||||
|
const totalLoops = runnerState?.card.loopMode === 'repeat_count'
|
||||||
|
? Math.max(1, Number(runnerState.card.loopCount || 1))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 进度计算
|
||||||
|
const stepDuration = currentStep?.durationSec || 0;
|
||||||
|
const stepProgress = currentStep && stepDuration > 0
|
||||||
|
? Math.max(0, Math.min(1, 1 - (runnerState?.remainingSec || 0) / stepDuration))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 预计剩余时间计算
|
||||||
|
const remainingStepsSec = (() => {
|
||||||
|
if (!runnerState) return 0;
|
||||||
|
const steps = runnerState.card.steps;
|
||||||
|
// 当前步骤剩余秒数
|
||||||
|
let total = runnerState.remainingSec || 0;
|
||||||
|
// 当前轮剩余步骤
|
||||||
|
for (let i = runnerState.currentStepIndex + 1; i < steps.length; i++) {
|
||||||
|
total += Math.max(1, Number(steps[i]?.durationSec || 0));
|
||||||
|
}
|
||||||
|
// 剩余轮次
|
||||||
|
const completedThisRound = runnerState.currentLoop + (stepProgress >= 1 ? 1 : 0);
|
||||||
|
const roundsLeft = totalLoops !== null ? Math.max(0, totalLoops - completedThisRound) : 0;
|
||||||
|
const perRoundSec = steps.reduce((sum, s) => sum + Math.max(1, Number(s.durationSec || 0)), 0);
|
||||||
|
total += roundsLeft * perRoundSec;
|
||||||
|
return total;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 全局总时长
|
||||||
|
const perRoundSec = runnerState?.card.steps.reduce((sum, s) => sum + Math.max(1, Number(s.durationSec || 0)), 0) || 0;
|
||||||
|
const globalDurationSec = totalLoops !== null ? perRoundSec * totalLoops : null;
|
||||||
|
|
||||||
|
// 全局进度计算
|
||||||
|
const executedSec = globalDurationSec !== null ? Math.max(0, globalDurationSec - remainingStepsSec) : 0;
|
||||||
|
const globalProgress = globalDurationSec !== null && globalDurationSec > 0
|
||||||
|
? Math.max(0, Math.min(1, executedSec / globalDurationSec))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
Alert.alert('结束执行', '确定提前结束本次执行吗?这会记录为手动提前结束。', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '结束',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
void finishSession('stopped', true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (action: 'pause' | 'resume' | 'skip') => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
if (action === 'pause') await pauseSession();
|
||||||
|
else if (action === 'resume') await resumeSession();
|
||||||
|
else if (action === 'skip') skipStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!runnerState) return null;
|
||||||
|
|
||||||
|
const isAction = currentStep?.type === 'action';
|
||||||
|
const stepBorderColor = isAction ? theme.colors.primary : theme.colors.success;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={!!runnerState} animationType="slide" onRequestClose={handleStop}>
|
||||||
|
<View style={styles.runnerContainer}>
|
||||||
|
<View style={styles.runnerHeader}>
|
||||||
|
<Text style={[styles.runnerBadge, { borderColor: stepBorderColor }]}>
|
||||||
|
{isAction ? '动作' : '休息'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.runnerTitle}>{runnerState.card.title}</Text>
|
||||||
|
<Text style={styles.runnerLoopText}>
|
||||||
|
第 {runnerState.currentLoop} 轮 {totalLoops ? `/ 共 ${totalLoops} 轮` : '/ 无限循环'}
|
||||||
|
{' · '}步骤 {runnerState.currentStepIndex + 1} / {totalSteps}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<View style={styles.progressBarContainer}>
|
||||||
|
<View style={[styles.progressBar, { width: `${stepProgress * 100}%` }]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.runnerMain}>
|
||||||
|
<Text style={styles.runnerLabel}>当前动作</Text>
|
||||||
|
<Text style={[styles.runnerCurrentText, isAction && { color: stepBorderColor }]}>{currentStep?.name || '—'}</Text>
|
||||||
|
<Text style={styles.runnerTimer}>{formatDuration(runnerState.remainingSec || 0)}</Text>
|
||||||
|
|
||||||
|
<View style={[styles.runnerNextCard, { borderColor: stepBorderColor }]}>
|
||||||
|
<Text style={styles.runnerLabel}>下一个动作</Text>
|
||||||
|
<Text style={styles.runnerNextText}>{nextStep?.name || '本轮结束'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.runnerFooter}>
|
||||||
|
{/* 执行信息卡片 */}
|
||||||
|
<View style={styles.runnerInfoCard}>
|
||||||
|
<View style={styles.runnerInfoRow}>
|
||||||
|
<Text style={styles.runnerInfoLabel}>已执行</Text>
|
||||||
|
<Text style={styles.runnerInfoValue}>
|
||||||
|
第 {runnerState.currentLoop} 轮,步骤 {runnerState.currentStepIndex + 1}/{totalSteps}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.runnerInfoRow}>
|
||||||
|
<Text style={styles.runnerInfoLabel}>预计剩余</Text>
|
||||||
|
<Text style={[styles.runnerInfoValue, styles.runnerInfoHighlight]}>
|
||||||
|
约 {formatDuration(remainingStepsSec)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.runnerInfoRow}>
|
||||||
|
<Text style={styles.runnerInfoLabel}>全局时长</Text>
|
||||||
|
<Text style={[styles.runnerInfoValue, styles.runnerInfoHighlight]}>
|
||||||
|
{globalDurationSec !== null
|
||||||
|
? `约 ${formatDuration(globalDurationSec)}`
|
||||||
|
: '无限循环'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 控制按钮 */}
|
||||||
|
<View style={styles.runnerButtonRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.runnerButton}
|
||||||
|
onPress={() => handleAction(runnerState.status === 'paused' ? 'resume' : 'pause')}
|
||||||
|
>
|
||||||
|
<Text style={styles.runnerButtonText}>{runnerState.status === 'paused' ? '继续' : '暂停'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.runnerButton} onPress={() => handleAction('skip')}>
|
||||||
|
<Text style={styles.runnerButtonText}>跳过</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.runnerButton, styles.runnerDangerButton]} onPress={handleStop}>
|
||||||
|
<Text style={styles.runnerButtonText}>结束</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 全局进度条 */}
|
||||||
|
{globalDurationSec !== null && globalDurationSec > 0 && (
|
||||||
|
<View style={styles.globalProgressWrap}>
|
||||||
|
<View style={styles.globalProgressHeader}>
|
||||||
|
<Text style={styles.globalProgressLabel}>执行进度</Text>
|
||||||
|
<Text style={styles.globalProgressPercent}>{Math.round(globalProgress * 100)}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.globalProgressBarContainer}>
|
||||||
|
<View style={[styles.globalProgressBar, { width: `${globalProgress * 100}%` }]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.globalProgressStats}>
|
||||||
|
<Text style={styles.globalProgressStat}>已执行 {formatDuration(executedSec)}</Text>
|
||||||
|
<Text style={styles.globalProgressStatDivider}>/</Text>
|
||||||
|
<Text style={styles.globalProgressStat}>总时长 {formatDuration(globalDurationSec)}</Text>
|
||||||
|
<Text style={styles.globalProgressStatDivider}>·</Text>
|
||||||
|
<Text style={[styles.globalProgressStat, styles.globalProgressRemaining]}>剩余 {formatDuration(remainingStepsSec)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
runnerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
paddingTop: 72,
|
||||||
|
paddingHorizontal: theme.spacing.lg,
|
||||||
|
paddingBottom: theme.spacing.xl,
|
||||||
|
},
|
||||||
|
runnerHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
runnerBadge: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
runnerTitle: {
|
||||||
|
...theme.typography.h2,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
runnerLoopText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
progressBarContainer: {
|
||||||
|
height: 10,
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: 5,
|
||||||
|
marginVertical: theme.spacing.lg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
borderRadius: 5,
|
||||||
|
},
|
||||||
|
runnerMain: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
},
|
||||||
|
runnerLabel: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
runnerCurrentText: {
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: theme.colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
runnerTimer: {
|
||||||
|
fontSize: 90,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
runnerNextCard: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
padding: theme.spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: theme.spacing.xl,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
runnerNextText: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: theme.colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
runnerFooter: {
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
},
|
||||||
|
runnerInfoCard: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
runnerInfoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
runnerInfoLabel: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
runnerInfoValue: {
|
||||||
|
...theme.typography.bodyLarge,
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
runnerInfoHighlight: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
runnerButtonRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
runnerButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.surfaceStrong,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
runnerDangerButton: {
|
||||||
|
backgroundColor: theme.colors.error,
|
||||||
|
},
|
||||||
|
runnerButtonText: {
|
||||||
|
...theme.typography.body,
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
// 全局进度条样式
|
||||||
|
globalProgressWrap: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing.lg,
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
marginTop: theme.spacing.md,
|
||||||
|
},
|
||||||
|
globalProgressHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
globalProgressLabel: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
globalProgressPercent: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
globalProgressBarContainer: {
|
||||||
|
height: 24,
|
||||||
|
backgroundColor: theme.colors.surfaceSoft,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
globalProgressBar: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
globalProgressStats: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
globalProgressStat: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
globalProgressStatDivider: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
marginHorizontal: 2,
|
||||||
|
},
|
||||||
|
globalProgressRemaining: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { AppState, AppStateStatus } from 'react-native';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const RUNNER_CACHE_KEY = '@order_box_motion_runner_state';
|
||||||
|
|
||||||
|
export interface CachedRunnerState {
|
||||||
|
sessionId: string;
|
||||||
|
currentLoop: number;
|
||||||
|
completedLoops: number;
|
||||||
|
currentStepIndex: number;
|
||||||
|
remainingSec: number;
|
||||||
|
status: 'running' | 'paused';
|
||||||
|
pausedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMotionAppStateReturn {
|
||||||
|
pauseRunner: () => void;
|
||||||
|
resumeRunner: () => Promise<CachedRunnerState | null>;
|
||||||
|
clearCache: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听 App 前后台切换,控制 Runner 暂停/恢复
|
||||||
|
* 进入后台时:暂停 Runner 并保存状态到 AsyncStorage
|
||||||
|
* 回到前台时:从 AsyncStorage 恢复状态
|
||||||
|
*/
|
||||||
|
export const useMotionAppState = (
|
||||||
|
onBackground: () => void,
|
||||||
|
onForeground: () => void
|
||||||
|
): UseMotionAppStateReturn => {
|
||||||
|
const appStateRef = useRef(AppState.currentState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
|
const wasBackground = appStateRef.current.match(/inactive|background/);
|
||||||
|
const isForeground = nextAppState === 'active';
|
||||||
|
|
||||||
|
if (!wasBackground && nextAppState.match(/inactive|background/)) {
|
||||||
|
// 进入后台
|
||||||
|
onBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasBackground && isForeground) {
|
||||||
|
// 回到前台
|
||||||
|
onForeground();
|
||||||
|
}
|
||||||
|
|
||||||
|
appStateRef.current = nextAppState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [onBackground, onForeground]);
|
||||||
|
|
||||||
|
const pauseRunner = async () => {
|
||||||
|
// 由外部调用,负责把当前 runner 状态存入缓存
|
||||||
|
// 具体实现在 RunnerModal 中
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeRunner = async (): Promise<CachedRunnerState | null> => {
|
||||||
|
try {
|
||||||
|
const cached = await AsyncStorage.getItem(RUNNER_CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache = async () => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem(RUNNER_CACHE_KEY);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { pauseRunner, resumeRunner, clearCache };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 Runner 状态到 AsyncStorage
|
||||||
|
*/
|
||||||
|
export const saveRunnerCache = async (state: CachedRunnerState): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(RUNNER_CACHE_KEY, JSON.stringify(state));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除 Runner 缓存
|
||||||
|
*/
|
||||||
|
export const clearRunnerCache = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem(RUNNER_CACHE_KEY);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import * as Speech from 'expo-speech';
|
||||||
|
import { motionReminderApi } from '../api';
|
||||||
|
import { MotionCard, SessionStatus } from '../types';
|
||||||
|
|
||||||
|
export interface RunnerState {
|
||||||
|
sessionId: string;
|
||||||
|
card: MotionCard;
|
||||||
|
status: SessionStatus;
|
||||||
|
currentLoop: number;
|
||||||
|
completedLoops: number;
|
||||||
|
currentStepIndex: number;
|
||||||
|
remainingSec: number;
|
||||||
|
manualStopped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseRunnerReturn {
|
||||||
|
runner: RunnerState | null;
|
||||||
|
startSession: (card: MotionCard) => Promise<void>;
|
||||||
|
pauseSession: () => Promise<void>;
|
||||||
|
resumeSession: () => Promise<void>;
|
||||||
|
skipStep: () => void;
|
||||||
|
finishSession: (status: 'completed' | 'stopped', manualStopped: boolean) => Promise<void>;
|
||||||
|
syncToServer: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSpeechDuration = (seconds: number) => {
|
||||||
|
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||||
|
const mm = Math.floor(safe / 60);
|
||||||
|
const ss = safe % 60;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (mm > 0) parts.push(`${mm}分钟`);
|
||||||
|
if (ss > 0) parts.push(`${ss}秒`);
|
||||||
|
if (!parts.length) parts.push('0秒');
|
||||||
|
return parts.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||||
|
const [runner, setRunner] = useState<RunnerState | null>(null);
|
||||||
|
const announcementRef = useRef('');
|
||||||
|
|
||||||
|
const finishRunner = useCallback(async (status: 'completed' | 'stopped', manualStopped: boolean) => {
|
||||||
|
const currentRunner = runner;
|
||||||
|
if (!currentRunner) return;
|
||||||
|
try {
|
||||||
|
await motionReminderApi.finishSession({
|
||||||
|
id: currentRunner.sessionId,
|
||||||
|
status,
|
||||||
|
currentLoop: currentRunner.currentLoop,
|
||||||
|
completedLoops: currentRunner.completedLoops,
|
||||||
|
currentStepIndex: currentRunner.currentStepIndex,
|
||||||
|
manualStopped,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '结束失败';
|
||||||
|
console.error('[Runner] 同步失败:', message);
|
||||||
|
} finally {
|
||||||
|
Speech.stop();
|
||||||
|
setRunner(null);
|
||||||
|
announcementRef.current = '';
|
||||||
|
onSessionEnd?.();
|
||||||
|
}
|
||||||
|
}, [runner, onSessionEnd]);
|
||||||
|
|
||||||
|
const advanceRunner = useCallback(() => {
|
||||||
|
setRunner((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const steps = prev.card.steps;
|
||||||
|
const isLastStep = prev.currentStepIndex >= steps.length - 1;
|
||||||
|
|
||||||
|
if (!isLastStep) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
currentStepIndex: prev.currentStepIndex + 1,
|
||||||
|
remainingSec: Math.max(1, steps[prev.currentStepIndex + 1].durationSec || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCompletedLoops = prev.completedLoops + 1;
|
||||||
|
const totalLoops = prev.card.loopMode === 'repeat_count'
|
||||||
|
? Math.max(1, Number(prev.card.loopCount || 1))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (prev.card.loopMode === 'once' || (totalLoops !== null && nextCompletedLoops >= totalLoops)) {
|
||||||
|
void finishRunner('completed', false);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
currentLoop: prev.currentLoop + 1,
|
||||||
|
completedLoops: nextCompletedLoops,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
remainingSec: Math.max(1, steps[0].durationSec || 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [finishRunner]);
|
||||||
|
|
||||||
|
// 倒计时逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
if (!runner || runner.status !== 'running') return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const step = runner.card.steps[runner.currentStepIndex];
|
||||||
|
const isAction = step?.type === 'action';
|
||||||
|
const countdownPitch = isAction ? 1.1 : 0.9;
|
||||||
|
|
||||||
|
// 5/3 秒倒计时播报
|
||||||
|
if (runner.remainingSec === 5 || runner.remainingSec === 3) {
|
||||||
|
Speech.stop();
|
||||||
|
Speech.speak(`还有 ${runner.remainingSec} 秒`, { language: 'zh-CN', pitch: countdownPitch, rate: 0.95 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runner.remainingSec <= 1) {
|
||||||
|
advanceRunner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunner((prev) => (prev ? { ...prev, remainingSec: prev.remainingSec - 1 } : prev));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [runner, advanceRunner]);
|
||||||
|
|
||||||
|
// 语音播报逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
if (!runner) {
|
||||||
|
announcementRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${runner.sessionId}:${runner.currentLoop}:${runner.currentStepIndex}`;
|
||||||
|
if (announcementRef.current === key) return;
|
||||||
|
announcementRef.current = key;
|
||||||
|
|
||||||
|
const step = runner.card.steps[runner.currentStepIndex];
|
||||||
|
const nextStep = runner.card.steps[runner.currentStepIndex + 1] || null;
|
||||||
|
if (!step) return;
|
||||||
|
|
||||||
|
// 动作步骤用较高音调,休息步骤用较低音调
|
||||||
|
const stepPitch = step.type === 'action' ? 1.1 : 0.9;
|
||||||
|
|
||||||
|
const title = step.voiceText || `${step.name},${formatSpeechDuration(step.durationSec || 0)}`;
|
||||||
|
const nextText = nextStep ? `。下一步,${nextStep.name}` : '';
|
||||||
|
Speech.stop();
|
||||||
|
Speech.speak(`第 ${runner.currentLoop} 轮,${title}${nextText}`, {
|
||||||
|
language: 'zh-CN',
|
||||||
|
pitch: stepPitch,
|
||||||
|
rate: 0.95,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步到服务端
|
||||||
|
void motionReminderApi.syncSession({
|
||||||
|
id: runner.sessionId,
|
||||||
|
currentLoop: runner.currentLoop,
|
||||||
|
completedLoops: runner.completedLoops,
|
||||||
|
currentStepIndex: runner.currentStepIndex,
|
||||||
|
status: runner.status,
|
||||||
|
manualStopped: runner.manualStopped,
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [runner]);
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Speech.stop();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startSession = async (card: MotionCard) => {
|
||||||
|
if (!card.steps.length) {
|
||||||
|
throw new Error('当前卡片没有可执行步骤');
|
||||||
|
}
|
||||||
|
const session = await motionReminderApi.startSession(card);
|
||||||
|
setRunner({
|
||||||
|
sessionId: session.id,
|
||||||
|
card: session.cardSnapshot,
|
||||||
|
status: 'running',
|
||||||
|
currentLoop: 1,
|
||||||
|
completedLoops: 0,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
remainingSec: Math.max(1, session.cardSnapshot.steps[0]?.durationSec || 1),
|
||||||
|
manualStopped: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pauseSession = async () => {
|
||||||
|
if (!runner) return;
|
||||||
|
setRunner({ ...runner, status: 'paused' });
|
||||||
|
try {
|
||||||
|
await motionReminderApi.syncSession({
|
||||||
|
id: runner.sessionId,
|
||||||
|
status: 'paused',
|
||||||
|
currentLoop: runner.currentLoop,
|
||||||
|
completedLoops: runner.completedLoops,
|
||||||
|
currentStepIndex: runner.currentStepIndex,
|
||||||
|
manualStopped: runner.manualStopped,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeSession = async () => {
|
||||||
|
if (!runner) return;
|
||||||
|
setRunner({ ...runner, status: 'running' });
|
||||||
|
try {
|
||||||
|
await motionReminderApi.syncSession({
|
||||||
|
id: runner.sessionId,
|
||||||
|
status: 'running',
|
||||||
|
currentLoop: runner.currentLoop,
|
||||||
|
completedLoops: runner.completedLoops,
|
||||||
|
currentStepIndex: runner.currentStepIndex,
|
||||||
|
manualStopped: runner.manualStopped,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipStep = () => {
|
||||||
|
advanceRunner();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncToServer = async () => {
|
||||||
|
if (!runner) return;
|
||||||
|
await motionReminderApi.syncSession({
|
||||||
|
id: runner.sessionId,
|
||||||
|
currentLoop: runner.currentLoop,
|
||||||
|
completedLoops: runner.completedLoops,
|
||||||
|
currentStepIndex: runner.currentStepIndex,
|
||||||
|
status: runner.status,
|
||||||
|
manualStopped: runner.manualStopped,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
runner,
|
||||||
|
startSession,
|
||||||
|
pauseSession,
|
||||||
|
resumeSession,
|
||||||
|
skipStep,
|
||||||
|
finishSession: finishRunner,
|
||||||
|
syncToServer,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { CardsScreen } from './screens/CardsScreen';
|
||||||
|
export { HistoryScreen } from './screens/HistoryScreen';
|
||||||
|
export { CardEditor } from './components/CardEditor';
|
||||||
|
export { RunnerModal } from './components/RunnerModal';
|
||||||
|
export { useRunner } from './hooks/useRunner';
|
||||||
|
export { useMotionAppState, saveRunnerCache, clearRunnerCache } from './hooks/useMotionAppState';
|
||||||
|
export { motionReminderApi } from './api';
|
||||||
|
export type * from './types';
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { motionReminderApi } from '../api';
|
||||||
|
import { useRunner } from '../hooks/useRunner';
|
||||||
|
import { CardEditor } from '../components/CardEditor';
|
||||||
|
import { RunnerModal } from '../components/RunnerModal';
|
||||||
|
import { theme } from '../../../theme';
|
||||||
|
import { MotionCard, MotionSession, OrderBoxState } from '../../../types';
|
||||||
|
|
||||||
|
interface CardsScreenProps {
|
||||||
|
onExit: () => void;
|
||||||
|
serverState: OrderBoxState | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||||
|
const mm = Math.floor(safe / 60);
|
||||||
|
const ss = safe % 60;
|
||||||
|
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLoopText = (loopMode: string, loopCount: number | null) => {
|
||||||
|
if (loopMode === 'once') return '单次执行';
|
||||||
|
if (loopMode === 'repeat_forever') return '无限循环';
|
||||||
|
return `循环 ${Math.max(1, Number(loopCount || 1))} 次`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, onRefresh }) => {
|
||||||
|
const [cards, setCards] = useState<MotionCard[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editorVisible, setEditorVisible] = useState(false);
|
||||||
|
const [editingCard, setEditingCard] = useState<MotionCard | null>(null);
|
||||||
|
|
||||||
|
const runner = useRunner(() => {
|
||||||
|
onRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const nextCards = await motionReminderApi.listCards();
|
||||||
|
setCards(nextCards);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '加载失败';
|
||||||
|
Alert.alert('加载失败', message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
void loadData();
|
||||||
|
onRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateCard = () => {
|
||||||
|
setEditingCard(null);
|
||||||
|
setEditorVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditCard = (card: MotionCard) => {
|
||||||
|
setEditingCard(card);
|
||||||
|
setEditorVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCard = (card: MotionCard) => {
|
||||||
|
Alert.alert('确认删除', `确定删除"${card.title}"吗?`, [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '删除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await motionReminderApi.deleteCard(card.id);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '删除失败';
|
||||||
|
Alert.alert('删除失败', message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCard = async (card: MotionCard) => {
|
||||||
|
if (!card.steps.length) {
|
||||||
|
Alert.alert('无法启动', '当前卡片没有可执行步骤');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await runner.startSession(card);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '启动失败';
|
||||||
|
Alert.alert('启动失败', message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearServerRunningState = async () => {
|
||||||
|
try {
|
||||||
|
await import('../../../api').then(({ orderBoxApi }) => orderBoxApi.resetRunningState());
|
||||||
|
handleRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '清理失败';
|
||||||
|
Alert.alert('清理失败', message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={onExit}>
|
||||||
|
<Ionicons name="chevron-back" size={18} color={theme.colors.text} />
|
||||||
|
<Text style={styles.backButtonText}>返回框子</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.primaryButton} onPress={openCreateCard}>
|
||||||
|
<Ionicons name="add" size={18} color="#081018" />
|
||||||
|
<Text style={styles.primaryButtonText}>新建卡片</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{serverState?.runningSession && !runner.runner ? (
|
||||||
|
<View style={styles.warnCard}>
|
||||||
|
<Text style={styles.warnCardTitle}>检测到服务端仍保留一个运行中的 session</Text>
|
||||||
|
<Text style={styles.warnCardText}>如果这是上次异常退出遗留,可以先清理占用再重新启动。</Text>
|
||||||
|
<TouchableOpacity style={styles.secondaryButton} onPress={clearServerRunningState}>
|
||||||
|
<Text style={styles.secondaryButtonText}>清理占用</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={cards}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.cardItem}>
|
||||||
|
<View style={styles.cardItemHeader}>
|
||||||
|
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||||
|
<Text style={styles.cardMeta}>{formatLoopText(item.loopMode, item.loopCount)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.cardSubText}>{item.steps.length} 个步骤</Text>
|
||||||
|
<View style={styles.stepPreviewWrap}>
|
||||||
|
{item.steps.slice(0, 4).map((step, index) => (
|
||||||
|
<Text key={step.id} style={styles.stepPreviewText}>
|
||||||
|
{index + 1}. {step.name} · {formatDuration(step.durationSec || 0)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{item.steps.length > 4 ? (
|
||||||
|
<Text style={styles.cardSubText}>还有 {item.steps.length - 4} 个步骤…</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardActions}>
|
||||||
|
<TouchableOpacity style={styles.primaryActionButton} onPress={() => startCard(item)}>
|
||||||
|
<Ionicons name="play" size={16} color="#081018" />
|
||||||
|
<Text style={styles.primaryActionButtonText}>启动</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.ghostActionButton} onPress={() => openEditCard(item)}>
|
||||||
|
<Text style={styles.ghostActionButtonText}>编辑</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.ghostActionButton} onPress={() => deleteCard(item)}>
|
||||||
|
<Text style={[styles.ghostActionButtonText, styles.dangerText]}>删除</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyCard}>
|
||||||
|
<Text style={styles.emptyTitle}>还没有流程卡片</Text>
|
||||||
|
<Text style={styles.emptyText}>先建一个训练流程,再从卡片直接启动执行界面。</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardEditor
|
||||||
|
visible={editorVisible}
|
||||||
|
initialCard={editingCard}
|
||||||
|
onSave={() => {
|
||||||
|
setEditorVisible(false);
|
||||||
|
handleRefresh();
|
||||||
|
}}
|
||||||
|
onClose={() => setEditorVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RunnerModal runner={runner} onClose={() => {}} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingTop: theme.spacing.md,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: '#081018',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
warnCard: {
|
||||||
|
margin: theme.spacing.md,
|
||||||
|
backgroundColor: theme.colors.surfaceSoft,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.warning,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
warnCardTitle: {
|
||||||
|
...theme.typography.bodyLarge,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
warnCardText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
},
|
||||||
|
cardItem: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
cardItemHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cardMeta: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
cardSubText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
stepPreviewWrap: {
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
stepPreviewText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
cardActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
marginTop: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
primaryActionButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
primaryActionButtonText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: '#081018',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
ghostActionButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
ghostActionButtonText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dangerText: {
|
||||||
|
color: theme.colors.error,
|
||||||
|
},
|
||||||
|
emptyCard: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { motionReminderApi } from '../api';
|
||||||
|
import { theme } from '../../../theme';
|
||||||
|
import { MotionSession } from '../types';
|
||||||
|
|
||||||
|
interface HistoryScreenProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (value?: string | null) => {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString('zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HistoryScreen: React.FC<HistoryScreenProps> = ({ onRefresh }) => {
|
||||||
|
const [history, setHistory] = useState<MotionSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const nextHistory = await motionReminderApi.listHistory();
|
||||||
|
setHistory(nextHistory);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '加载失败';
|
||||||
|
Alert.alert('加载失败', message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
void loadData();
|
||||||
|
onRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={history}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.cardItem}>
|
||||||
|
<Text style={styles.cardTitle}>{item.cardSnapshot?.title || '未知流程'}</Text>
|
||||||
|
<Text style={styles.historyLine}>开始:{formatDateTime(item.startedAt)}</Text>
|
||||||
|
<Text style={styles.historyLine}>结束:{formatDateTime(item.endedAt)}</Text>
|
||||||
|
<Text style={styles.historyLine}>完成轮次:{Math.max(0, Number(item.completedLoops || 0))}</Text>
|
||||||
|
<Text style={styles.historyLine}>
|
||||||
|
结束方式:{item.manualStopped ? '手动提前结束' : item.status === 'completed' ? '正常完成' : '停止'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyCard}>
|
||||||
|
<Text style={styles.emptyTitle}>还没有执行记录</Text>
|
||||||
|
<Text style={styles.emptyText}>从卡片页启动一次流程后,这里会出现历史记录。</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
},
|
||||||
|
cardItem: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
historyLine: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
emptyCard: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
...theme.typography.h3,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
...theme.typography.bodySmall,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export type MotionStepType = 'action' | 'rest';
|
||||||
|
export type LoopMode = 'once' | 'repeat_count' | 'repeat_forever';
|
||||||
|
export type SessionStatus = 'running' | 'paused' | 'completed' | 'stopped';
|
||||||
|
|
||||||
|
export interface MotionStep {
|
||||||
|
id: string;
|
||||||
|
type: MotionStepType;
|
||||||
|
name: string;
|
||||||
|
durationSec: number | null;
|
||||||
|
voiceText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionCard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
steps: MotionStep[];
|
||||||
|
loopMode: LoopMode;
|
||||||
|
loopCount: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionSession {
|
||||||
|
id: string;
|
||||||
|
kitId: 'motion_reminder';
|
||||||
|
cardId: string;
|
||||||
|
cardSnapshot: MotionCard;
|
||||||
|
status: SessionStatus;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
currentLoop: number;
|
||||||
|
completedLoops: number;
|
||||||
|
currentStepIndex: number;
|
||||||
|
manualStopped: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
ok: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "app-order-box",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "node_modules/expo/AppEntry.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||||
|
"@react-navigation/native": "^6.1.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"expo": "~54.0.0",
|
||||||
|
"expo-haptics": "^57.0.0",
|
||||||
|
"expo-keep-awake": "^57.0.0",
|
||||||
|
"expo-speech": "~13.0.1",
|
||||||
|
"expo-splash-screen": "~31.0.10",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-worklets": "^0.10.0",
|
||||||
|
"react-native-worklets-core": "^1.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.0",
|
||||||
|
"@types/react": "~19.1.10",
|
||||||
|
"@types/react-native": "~0.73.0",
|
||||||
|
"babel-preset-expo": "~54.0.10",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { TextStyle } from 'react-native';
|
||||||
|
|
||||||
|
export const theme = {
|
||||||
|
colors: {
|
||||||
|
background: '#080b12',
|
||||||
|
surface: '#121826',
|
||||||
|
surfaceSoft: '#1a2234',
|
||||||
|
surfaceStrong: '#202b42',
|
||||||
|
primary: '#f0b35f',
|
||||||
|
primarySoft: 'rgba(240, 179, 95, 0.16)',
|
||||||
|
text: '#f6f7fb',
|
||||||
|
textSecondary: '#9ba7bc',
|
||||||
|
textDisabled: '#5a6578',
|
||||||
|
border: '#2a3449',
|
||||||
|
success: '#4ade80',
|
||||||
|
warning: '#fbbf24',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 18,
|
||||||
|
xl: 24,
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
hero: { fontSize: 54, fontWeight: '700' } as TextStyle,
|
||||||
|
h1: { fontSize: 28, fontWeight: '700' } as TextStyle,
|
||||||
|
h2: { fontSize: 24, fontWeight: '700' } as TextStyle,
|
||||||
|
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
|
||||||
|
body: { fontSize: 16, fontWeight: '400' } as TextStyle,
|
||||||
|
bodyLarge: { fontSize: 18, fontWeight: '500' } as TextStyle,
|
||||||
|
bodySmall: { fontSize: 14, fontWeight: '400' } as TextStyle,
|
||||||
|
caption: { fontSize: 12, fontWeight: '400' } as TextStyle,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export type OrderBoxKitId = 'motion_reminder';
|
||||||
|
|
||||||
|
export type MotionStepType = 'action' | 'rest';
|
||||||
|
export type LoopMode = 'once' | 'repeat_count' | 'repeat_forever';
|
||||||
|
export type SessionStatus = 'running' | 'paused' | 'completed' | 'stopped';
|
||||||
|
|
||||||
|
export interface MotionStep {
|
||||||
|
id: string;
|
||||||
|
type: MotionStepType;
|
||||||
|
name: string;
|
||||||
|
durationSec: number | null;
|
||||||
|
voiceText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionCard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
steps: MotionStep[];
|
||||||
|
loopMode: LoopMode;
|
||||||
|
loopCount: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionSession {
|
||||||
|
id: string;
|
||||||
|
kitId: OrderBoxKitId;
|
||||||
|
cardId: string;
|
||||||
|
cardSnapshot: MotionCard;
|
||||||
|
status: SessionStatus;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
currentLoop: number;
|
||||||
|
completedLoops: number;
|
||||||
|
currentStepIndex: number;
|
||||||
|
manualStopped: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderBoxMeta {
|
||||||
|
ok: boolean;
|
||||||
|
appName: string;
|
||||||
|
appNameZh: string;
|
||||||
|
kits: Array<{
|
||||||
|
id: OrderBoxKitId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderBoxState {
|
||||||
|
ok: boolean;
|
||||||
|
activeKit: OrderBoxKitId | null;
|
||||||
|
runningSession: {
|
||||||
|
kitId: OrderBoxKitId;
|
||||||
|
sessionId: string;
|
||||||
|
updatedAt: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
ok: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"urlRandomness": "oVzuXCs"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,805 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { StatusBar, Alert, ActivityIndicator, Image, ImageBackground, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { Audio, AVPlaybackStatus } from 'expo-av';
|
||||||
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import { theme } from './theme';
|
||||||
|
import { Track, RepeatMode, CacheInfo } from './types';
|
||||||
|
import * as API from './api';
|
||||||
|
import * as Cache from './utils/cache';
|
||||||
|
import { PlayerScreen } from './screens/PlayerScreen';
|
||||||
|
import { PlaylistScreen } from './screens/PlaylistScreen';
|
||||||
|
import { SettingsScreen } from './screens/SettingsScreen';
|
||||||
|
|
||||||
|
// 安全地编码 URL 中的路径部分
|
||||||
|
const encodeUrlPath = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const pathParts = parsedUrl.pathname.split('/');
|
||||||
|
const encodedPathParts = pathParts.map((part, index) => {
|
||||||
|
if (part === '') return part;
|
||||||
|
return encodeURIComponent(decodeURIComponent(part));
|
||||||
|
});
|
||||||
|
parsedUrl.pathname = encodedPathParts.join('/');
|
||||||
|
return parsedUrl.toString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[URL] 编码失败:', error);
|
||||||
|
try {
|
||||||
|
return encodeURI(url);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
void SplashScreen.preventAutoHideAsync().catch(() => {});
|
||||||
|
SplashScreen.setOptions({
|
||||||
|
duration: 500,
|
||||||
|
fade: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlayIconNav = ({ color, size }: { color: string; size: number }) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||||
|
<Path d="M8 5v14l11-7z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ListIconNav = ({ color, size }: { color: string; size: number }) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round">
|
||||||
|
<Path d="M8 6h13M8 12h13M8 18h13" />
|
||||||
|
<Path d="M3 6h.01M3 12h.01M3 18h.01" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SettingsIconNav = ({ color, size }: { color: string; size: number }) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M12 15a3 3 0 1 0 0-6a3 3 0 0 0 0 6z" />
|
||||||
|
<Path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83a2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2a2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0a2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2a2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83a2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51v-.09a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0a2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2a2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StartupLoadingScreen = () => (
|
||||||
|
<ImageBackground
|
||||||
|
source={require('./assets/splash.png')}
|
||||||
|
resizeMode="cover"
|
||||||
|
style={loadingStyles.background}
|
||||||
|
>
|
||||||
|
<View style={loadingStyles.overlay}>
|
||||||
|
<View style={loadingStyles.badgeWrap}>
|
||||||
|
<Image source={require('./assets/icon.png')} style={loadingStyles.badgeImage} />
|
||||||
|
</View>
|
||||||
|
<Text style={loadingStyles.title}>观空禅音</Text>
|
||||||
|
<Text style={loadingStyles.subtitle}>正在加载歌单与缓存...</Text>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.primary} style={loadingStyles.spinner} />
|
||||||
|
</View>
|
||||||
|
</ImageBackground>
|
||||||
|
);
|
||||||
|
|
||||||
|
type SortMode = 'original' | 'asc' | 'desc';
|
||||||
|
|
||||||
|
const TabNavigator = () => {
|
||||||
|
const [tracks, setTracks] = useState<Track[]>([]);
|
||||||
|
const [cacheInfo, setCacheInfo] = useState<CacheInfo>({});
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isShuffle, setIsShuffle] = useState(false);
|
||||||
|
const [repeatMode, setRepeatMode] = useState<RepeatMode>('none');
|
||||||
|
const [position, setPosition] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState({ completed: 0, total: 0, title: '' });
|
||||||
|
const [downloadingTrackSrcs, setDownloadingTrackSrcs] = useState<string[]>([]);
|
||||||
|
const [cacheOnly, setCacheOnly] = useState(true);
|
||||||
|
const [playlistFiles, setPlaylistFiles] = useState<string[]>(['playlist.json']);
|
||||||
|
const [currentPlaylistFile, setCurrentPlaylistFile] = useState('playlist.json');
|
||||||
|
const [resumePlaybackEnabled, setResumePlaybackEnabled] = useState(true);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const soundRef = useRef<Audio.Sound | null>(null);
|
||||||
|
const shuffleOrderRef = useRef<number[]>([]);
|
||||||
|
const playbackOrderRef = useRef<number[]>([]);
|
||||||
|
const tracksRef = useRef<Track[]>([]);
|
||||||
|
const currentIndexRef = useRef(0);
|
||||||
|
const isPlayingRef = useRef(false);
|
||||||
|
const isShuffleRef = useRef(false);
|
||||||
|
const repeatModeRef = useRef<RepeatMode>('none');
|
||||||
|
const cacheOnlyRef = useRef(true);
|
||||||
|
const positionRef = useRef(0);
|
||||||
|
const durationRef = useRef(0);
|
||||||
|
const currentPlaylistFileRef = useRef('playlist.json');
|
||||||
|
const resumePlaybackEnabledRef = useRef(true);
|
||||||
|
const lastSavedPositionRef = useRef(0);
|
||||||
|
const setTracksState = (nextTracks: Track[]) => {
|
||||||
|
tracksRef.current = nextTracks;
|
||||||
|
playbackOrderRef.current = nextTracks.map((_, index) => index);
|
||||||
|
setTracks(nextTracks);
|
||||||
|
};
|
||||||
|
const setCurrentIndexState = (index: number) => {
|
||||||
|
currentIndexRef.current = index;
|
||||||
|
setCurrentIndex(index);
|
||||||
|
};
|
||||||
|
const setIsPlayingState = (value: boolean) => {
|
||||||
|
isPlayingRef.current = value;
|
||||||
|
setIsPlaying(value);
|
||||||
|
};
|
||||||
|
const setIsShuffleState = (value: boolean) => {
|
||||||
|
isShuffleRef.current = value;
|
||||||
|
setIsShuffle(value);
|
||||||
|
};
|
||||||
|
const setRepeatModeState = (value: RepeatMode) => {
|
||||||
|
repeatModeRef.current = value;
|
||||||
|
setRepeatMode(value);
|
||||||
|
};
|
||||||
|
const setCacheOnlyState = (value: boolean) => {
|
||||||
|
cacheOnlyRef.current = value;
|
||||||
|
setCacheOnly(value);
|
||||||
|
};
|
||||||
|
const setCurrentPlaylistFileState = (value: string) => {
|
||||||
|
currentPlaylistFileRef.current = value;
|
||||||
|
setCurrentPlaylistFile(value);
|
||||||
|
};
|
||||||
|
const setResumePlaybackEnabledState = (value: boolean) => {
|
||||||
|
resumePlaybackEnabledRef.current = value;
|
||||||
|
setResumePlaybackEnabled(value);
|
||||||
|
};
|
||||||
|
const setPositionState = (value: number) => {
|
||||||
|
positionRef.current = value;
|
||||||
|
setPosition(value);
|
||||||
|
};
|
||||||
|
const setDurationState = (value: number) => {
|
||||||
|
durationRef.current = value;
|
||||||
|
setDuration(value);
|
||||||
|
};
|
||||||
|
const refreshCacheInfo = () => {
|
||||||
|
setCacheInfo(Cache.getCacheInfo());
|
||||||
|
};
|
||||||
|
const persistPlaybackPosition = async (
|
||||||
|
trackIndex = currentIndexRef.current,
|
||||||
|
positionSeconds = positionRef.current,
|
||||||
|
durationSeconds = durationRef.current,
|
||||||
|
options?: { force?: boolean }
|
||||||
|
) => {
|
||||||
|
if (!resumePlaybackEnabledRef.current) return;
|
||||||
|
const track = tracksRef.current[trackIndex];
|
||||||
|
if (!track || !track.src) return;
|
||||||
|
|
||||||
|
const safePosition = Math.max(0, Math.floor(positionSeconds || 0));
|
||||||
|
const safeDuration = Math.max(0, Math.floor(durationSeconds || 0));
|
||||||
|
const isNearEnd = safeDuration > 0 && safeDuration - safePosition <= 30;
|
||||||
|
|
||||||
|
if (safePosition < 5 || isNearEnd) {
|
||||||
|
await API.clearPlaybackPosition(currentPlaylistFileRef.current, track.src);
|
||||||
|
lastSavedPositionRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.force && Math.abs(safePosition - lastSavedPositionRef.current) < 15) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await API.savePlaybackPosition(currentPlaylistFileRef.current, track.src, safePosition);
|
||||||
|
lastSavedPositionRef.current = safePosition;
|
||||||
|
};
|
||||||
|
const applyPlaylistState = (nextTracks: Track[], playlistFile: string) => {
|
||||||
|
const previousTrackSrc = tracksRef.current[currentIndexRef.current]?.src || '';
|
||||||
|
const wasPlaying = isPlayingRef.current;
|
||||||
|
const matchedIndex = previousTrackSrc
|
||||||
|
? nextTracks.findIndex((track) => track.src === previousTrackSrc)
|
||||||
|
: -1;
|
||||||
|
const nextIndex = matchedIndex >= 0 ? matchedIndex : 0;
|
||||||
|
|
||||||
|
setTracksState(nextTracks);
|
||||||
|
buildShuffleOrder(nextTracks.length);
|
||||||
|
|
||||||
|
if (!nextTracks.length) {
|
||||||
|
setCurrentPlaylistFileState(playlistFile);
|
||||||
|
if (soundRef.current) {
|
||||||
|
void soundRef.current.unloadAsync();
|
||||||
|
soundRef.current = null;
|
||||||
|
}
|
||||||
|
setCurrentIndexState(0);
|
||||||
|
setPositionState(0);
|
||||||
|
setIsPlayingState(false);
|
||||||
|
setDurationState(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (soundRef.current) {
|
||||||
|
void loadTrack(nextIndex, wasPlaying && matchedIndex >= 0, playlistFile);
|
||||||
|
} else {
|
||||||
|
setCurrentPlaylistFileState(playlistFile);
|
||||||
|
setCurrentIndexState(nextIndex);
|
||||||
|
setPositionState(0);
|
||||||
|
setDurationState(0);
|
||||||
|
setIsPlayingState(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPlaylistBundle = async (
|
||||||
|
playlistFile: string,
|
||||||
|
options?: { showSnapshotAlert?: boolean }
|
||||||
|
) => {
|
||||||
|
const playlistResult = await API.getPlaylistWithFallback(playlistFile);
|
||||||
|
applyPlaylistState(playlistResult.tracks, playlistResult.playlistFile);
|
||||||
|
await API.saveSelectedPlaylist(playlistResult.playlistFile);
|
||||||
|
refreshCacheInfo();
|
||||||
|
|
||||||
|
if (options?.showSnapshotAlert && playlistResult.source === 'snapshot') {
|
||||||
|
Alert.alert(
|
||||||
|
'离线歌单',
|
||||||
|
`远程歌单加载失败,已自动切换到本地歌单快照继续使用。\n\n原因:${playlistResult.fallbackReason || '网络不可用'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlistResult;
|
||||||
|
};
|
||||||
|
const getPlaybackOrder = () => {
|
||||||
|
const currentTracks = tracksRef.current;
|
||||||
|
const fallbackOrder = currentTracks.map((_, index) => index);
|
||||||
|
const currentOrder = playbackOrderRef.current;
|
||||||
|
|
||||||
|
if (currentOrder.length !== currentTracks.length) {
|
||||||
|
return fallbackOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueIndexes = new Set(currentOrder);
|
||||||
|
if (uniqueIndexes.size !== currentTracks.length) {
|
||||||
|
return fallbackOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentOrder;
|
||||||
|
};
|
||||||
|
const handlePlaybackOrderChange = (order: number[]) => {
|
||||||
|
playbackOrderRef.current = order;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
await Cache.initCache();
|
||||||
|
refreshCacheInfo();
|
||||||
|
await Audio.setAudioModeAsync({
|
||||||
|
playsInSilentModeIOS: true,
|
||||||
|
staysActiveInBackground: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheOnlySetting = await Cache.getCacheOnlySetting();
|
||||||
|
setCacheOnlyState(cacheOnlySetting);
|
||||||
|
const resumePlaybackSetting = await API.getResumePlaybackEnabled();
|
||||||
|
setResumePlaybackEnabledState(resumePlaybackSetting);
|
||||||
|
|
||||||
|
const files = await API.getPlaylistFiles();
|
||||||
|
setPlaylistFiles(files);
|
||||||
|
const savedPlaylist = await API.getSavedSelectedPlaylist();
|
||||||
|
const initialPlaylist = files.includes(savedPlaylist) ? savedPlaylist : 'playlist.json';
|
||||||
|
await loadPlaylistBundle(initialPlaylist, { showSnapshotAlert: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Init failed:', error);
|
||||||
|
Alert.alert('错误', '无法加载播放列表');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
void persistPlaybackPosition(currentIndexRef.current, positionRef.current, durationRef.current, { force: true });
|
||||||
|
if (soundRef.current) {
|
||||||
|
soundRef.current.unloadAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
void SplashScreen.hideAsync().catch(() => {});
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
// Build shuffle order
|
||||||
|
const buildShuffleOrder = (length: number) => {
|
||||||
|
const order = [...Array(length).keys()];
|
||||||
|
for (let i = order.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[order[i], order[j]] = [order[j], order[i]];
|
||||||
|
}
|
||||||
|
shuffleOrderRef.current = order;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load track
|
||||||
|
const loadTrack = async (index: number, autoPlay = false, playlistFileOverride?: string) => {
|
||||||
|
try {
|
||||||
|
const track = tracksRef.current[index];
|
||||||
|
if (!track) return;
|
||||||
|
const previousIndex = currentIndexRef.current;
|
||||||
|
const targetPlaylistFile = String(playlistFileOverride || currentPlaylistFileRef.current || 'playlist.json');
|
||||||
|
if (soundRef.current && tracksRef.current[previousIndex]) {
|
||||||
|
await persistPlaybackPosition(previousIndex, positionRef.current, durationRef.current, { force: true });
|
||||||
|
}
|
||||||
|
if (currentPlaylistFileRef.current !== targetPlaylistFile) {
|
||||||
|
setCurrentPlaylistFileState(targetPlaylistFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceUri: string;
|
||||||
|
const cachedPath = Cache.getCachedPath(track.src);
|
||||||
|
|
||||||
|
if (cacheOnlyRef.current && !cachedPath) {
|
||||||
|
// 只用缓存模式,但歌曲未缓存
|
||||||
|
Alert.alert(
|
||||||
|
'无法播放',
|
||||||
|
`歌曲"${track.title}"未缓存,且当前设置为只用缓存听歌曲。\n\n请先在播放列表页面下载此歌曲。`,
|
||||||
|
[{ text: '好的', style: 'default' }]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedPath) {
|
||||||
|
sourceUri = cachedPath;
|
||||||
|
} else {
|
||||||
|
// 对原始 URL 进行编码
|
||||||
|
sourceUri = encodeUrlPath(track.src);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedPositionSeconds = resumePlaybackEnabledRef.current
|
||||||
|
? await API.getSavedPlaybackPosition(targetPlaylistFile, track.src)
|
||||||
|
: 0;
|
||||||
|
const initialPositionMillis = savedPositionSeconds > 5 ? savedPositionSeconds * 1000 : 0;
|
||||||
|
const activeTrack = track;
|
||||||
|
|
||||||
|
const { sound, status } = await Audio.Sound.createAsync(
|
||||||
|
{ uri: sourceUri },
|
||||||
|
{
|
||||||
|
shouldPlay: autoPlay,
|
||||||
|
isLooping: repeatModeRef.current === 'one',
|
||||||
|
progressUpdateIntervalMillis: 250,
|
||||||
|
...(initialPositionMillis > 0 ? { positionMillis: initialPositionMillis } : {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sound.setOnPlaybackStatusUpdate((playbackStatus: AVPlaybackStatus) => {
|
||||||
|
if (soundRef.current !== sound) return;
|
||||||
|
|
||||||
|
if (!playbackStatus.isLoaded) {
|
||||||
|
if (playbackStatus.error) {
|
||||||
|
console.error('Playback status error:', playbackStatus.error);
|
||||||
|
}
|
||||||
|
setIsPlayingState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPositionState(playbackStatus.positionMillis / 1000);
|
||||||
|
setDurationState(playbackStatus.durationMillis ? playbackStatus.durationMillis / 1000 : 0);
|
||||||
|
setIsPlayingState(playbackStatus.isPlaying);
|
||||||
|
|
||||||
|
if (playbackStatus.isPlaying) {
|
||||||
|
void persistPlaybackPosition(
|
||||||
|
index,
|
||||||
|
playbackStatus.positionMillis / 1000,
|
||||||
|
playbackStatus.durationMillis ? playbackStatus.durationMillis / 1000 : 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playbackStatus.didJustFinish) {
|
||||||
|
void API.clearPlaybackPosition(targetPlaylistFile, activeTrack.src);
|
||||||
|
lastSavedPositionRef.current = 0;
|
||||||
|
void handleTrackEnd();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousSound = soundRef.current;
|
||||||
|
soundRef.current = sound;
|
||||||
|
setCurrentIndexState(index);
|
||||||
|
if (status.isLoaded) {
|
||||||
|
setPositionState(status.positionMillis / 1000);
|
||||||
|
setDurationState(status.durationMillis ? status.durationMillis / 1000 : 0);
|
||||||
|
setIsPlayingState(autoPlay || status.isPlaying);
|
||||||
|
lastSavedPositionRef.current = status.positionMillis > 0 ? Math.floor(status.positionMillis / 1000) : 0;
|
||||||
|
} else {
|
||||||
|
setPositionState(0);
|
||||||
|
setDurationState(0);
|
||||||
|
setIsPlayingState(false);
|
||||||
|
lastSavedPositionRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousSound) {
|
||||||
|
previousSound.setOnPlaybackStatusUpdate(null);
|
||||||
|
await previousSound.unloadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoPlay && status.isLoaded && !status.isPlaying) {
|
||||||
|
await sound.playAsync();
|
||||||
|
setIsPlayingState(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load track:', error);
|
||||||
|
Alert.alert('错误', '无法加载歌曲');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle track end
|
||||||
|
const handleTrackEnd = async () => {
|
||||||
|
if (repeatModeRef.current === 'one') {
|
||||||
|
if (soundRef.current) {
|
||||||
|
await soundRef.current.replayAsync();
|
||||||
|
setIsPlayingState(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTrack(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Play/Pause
|
||||||
|
const togglePlayPause = async () => {
|
||||||
|
if (!soundRef.current) {
|
||||||
|
await loadTrack(currentIndexRef.current, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlayingRef.current) {
|
||||||
|
setIsPlayingState(false);
|
||||||
|
await soundRef.current.pauseAsync();
|
||||||
|
await persistPlaybackPosition(currentIndexRef.current, positionRef.current, durationRef.current, { force: true });
|
||||||
|
} else {
|
||||||
|
setIsPlayingState(true);
|
||||||
|
await soundRef.current.playAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find next cached track
|
||||||
|
const findNextCachedTrack = (startIndex: number): number | null => {
|
||||||
|
const currentTracks = tracksRef.current;
|
||||||
|
if (!cacheOnlyRef.current) return startIndex;
|
||||||
|
|
||||||
|
for (let i = 0; i < currentTracks.length; i++) {
|
||||||
|
const idx = (startIndex + i) % currentTracks.length;
|
||||||
|
if (Cache.isCached(currentTracks[idx].src)) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findPrevCachedTrack = (startIndex: number): number | null => {
|
||||||
|
const currentTracks = tracksRef.current;
|
||||||
|
if (!cacheOnlyRef.current) return startIndex;
|
||||||
|
|
||||||
|
for (let i = 0; i < currentTracks.length; i++) {
|
||||||
|
const idx = (startIndex - i + currentTracks.length) % currentTracks.length;
|
||||||
|
if (Cache.isCached(currentTracks[idx].src)) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Next track
|
||||||
|
const nextTrack = async (forceAutoPlay?: boolean) => {
|
||||||
|
const currentTracks = tracksRef.current;
|
||||||
|
if (!currentTracks.length) return;
|
||||||
|
|
||||||
|
const playbackOrder = getPlaybackOrder();
|
||||||
|
let nextIndex;
|
||||||
|
if (isShuffleRef.current) {
|
||||||
|
const currentPos = shuffleOrderRef.current.indexOf(currentIndexRef.current);
|
||||||
|
nextIndex = shuffleOrderRef.current[(currentPos + 1) % shuffleOrderRef.current.length];
|
||||||
|
} else {
|
||||||
|
const currentPos = playbackOrder.indexOf(currentIndexRef.current);
|
||||||
|
const safePos = currentPos >= 0 ? currentPos : 0;
|
||||||
|
nextIndex = playbackOrder[(safePos + 1) % playbackOrder.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isShuffleRef.current &&
|
||||||
|
repeatModeRef.current === 'none' &&
|
||||||
|
playbackOrder.indexOf(currentIndexRef.current) === playbackOrder.length - 1
|
||||||
|
) {
|
||||||
|
// End of playlist, stop
|
||||||
|
await soundRef.current?.pauseAsync();
|
||||||
|
await soundRef.current?.setPositionAsync(0);
|
||||||
|
setPositionState(0);
|
||||||
|
setIsPlayingState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是缓存模式,找下一个已缓存的歌曲
|
||||||
|
const finalIndex = findNextCachedTrack(nextIndex);
|
||||||
|
if (finalIndex === null) {
|
||||||
|
Alert.alert('提示', '没有已缓存的歌曲可以播放,请先下载一些歌曲');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTrack(finalIndex, forceAutoPlay ?? isPlayingRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Previous track
|
||||||
|
const prevTrack = async () => {
|
||||||
|
const currentTracks = tracksRef.current;
|
||||||
|
if (!currentTracks.length) return;
|
||||||
|
const playbackOrder = getPlaybackOrder();
|
||||||
|
|
||||||
|
if (positionRef.current > 3) {
|
||||||
|
await soundRef.current?.setPositionAsync(0);
|
||||||
|
setPositionState(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevIndex;
|
||||||
|
if (isShuffleRef.current) {
|
||||||
|
const currentPos = shuffleOrderRef.current.indexOf(currentIndexRef.current);
|
||||||
|
prevIndex = shuffleOrderRef.current[(currentPos - 1 + shuffleOrderRef.current.length) % shuffleOrderRef.current.length];
|
||||||
|
} else {
|
||||||
|
const currentPos = playbackOrder.indexOf(currentIndexRef.current);
|
||||||
|
const safePos = currentPos >= 0 ? currentPos : 0;
|
||||||
|
prevIndex = playbackOrder[(safePos - 1 + playbackOrder.length) % playbackOrder.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalIndex = findPrevCachedTrack(prevIndex);
|
||||||
|
if (finalIndex === null) {
|
||||||
|
Alert.alert('提示', '没有已缓存的歌曲可以播放,请先下载一些歌曲');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTrack(finalIndex, isPlayingRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle shuffle
|
||||||
|
const toggleShuffle = () => {
|
||||||
|
const nextShuffle = !isShuffleRef.current;
|
||||||
|
setIsShuffleState(nextShuffle);
|
||||||
|
if (nextShuffle) {
|
||||||
|
buildShuffleOrder(tracksRef.current.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle repeat
|
||||||
|
const toggleRepeat = () => {
|
||||||
|
const modes: RepeatMode[] = ['none', 'one', 'all'];
|
||||||
|
const currentPos = modes.indexOf(repeatModeRef.current);
|
||||||
|
const nextMode = modes[(currentPos + 1) % modes.length];
|
||||||
|
setRepeatModeState(nextMode);
|
||||||
|
|
||||||
|
if (soundRef.current) {
|
||||||
|
void soundRef.current.setIsLoopingAsync(nextMode === 'one');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seek
|
||||||
|
const seek = async (newPosition: number) => {
|
||||||
|
if (soundRef.current) {
|
||||||
|
await soundRef.current.setPositionAsync(newPosition * 1000);
|
||||||
|
setPositionState(newPosition);
|
||||||
|
await persistPlaybackPosition(currentIndexRef.current, newPosition, durationRef.current, { force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCacheOnlyChange = async (value: boolean) => {
|
||||||
|
setCacheOnlyState(value);
|
||||||
|
try {
|
||||||
|
await Cache.setCacheOnlySetting(value);
|
||||||
|
} catch (error) {
|
||||||
|
setCacheOnlyState(!value);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaylistChange = async (playlistFile: string) => {
|
||||||
|
try {
|
||||||
|
await loadPlaylistBundle(playlistFile, { showSnapshotAlert: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换歌单失败:', error);
|
||||||
|
Alert.alert('错误', '切换歌单失败');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumePlaybackChange = async (value: boolean) => {
|
||||||
|
setResumePlaybackEnabledState(value);
|
||||||
|
try {
|
||||||
|
await API.saveResumePlaybackEnabled(value);
|
||||||
|
} catch (error) {
|
||||||
|
setResumePlaybackEnabledState(!value);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTrack = async (track: Track) => {
|
||||||
|
if (downloadingTrackSrcs.includes(track.src)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadingTrackSrcs((prev) => [...prev, track.src]);
|
||||||
|
try {
|
||||||
|
await Cache.downloadAndCache(track.src);
|
||||||
|
refreshCacheInfo();
|
||||||
|
} finally {
|
||||||
|
setDownloadingTrackSrcs((prev) => prev.filter((src) => src !== track.src));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download all tracks
|
||||||
|
const downloadAllTracks = async () => {
|
||||||
|
setIsDownloading(true);
|
||||||
|
setDownloadProgress({ completed: 0, total: tracks.length, title: '准备下载...' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Cache.downloadAllTracks(tracks, (completed, total, title) => {
|
||||||
|
setDownloadProgress({ completed, total, title });
|
||||||
|
console.log(`Downloading ${completed}/${total}: ${title}`);
|
||||||
|
});
|
||||||
|
refreshCacheInfo();
|
||||||
|
|
||||||
|
if (result.failedTracks.length > 0) {
|
||||||
|
Alert.alert(
|
||||||
|
'部分下载失败',
|
||||||
|
`成功下载 ${result.successCount} 首,跳过已缓存 ${result.skippedCount} 首,失败 ${result.failedTracks.length} 首。\n\n失败歌曲:${result.failedTracks.join('、')}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'完成',
|
||||||
|
`下载完成。新缓存 ${result.successCount} 首,已缓存跳过 ${result.skippedCount} 首。`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量下载失败:', error);
|
||||||
|
Alert.alert('错误', '批量下载时发生异常,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
setDownloadProgress({ completed: 0, total: 0, title: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPlaylist = () => {
|
||||||
|
// Use navigation to switch to Playlist tab
|
||||||
|
// @ts-ignore
|
||||||
|
navigation.navigate('Playlist');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <StartupLoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
tabBarActiveTintColor: theme.colors.primary,
|
||||||
|
tabBarInactiveTintColor: theme.colors.textSecondary,
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Player"
|
||||||
|
children={() => (
|
||||||
|
<PlayerScreen
|
||||||
|
currentTrack={tracks[currentIndex]}
|
||||||
|
trackCount={tracks.length}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isShuffle={isShuffle}
|
||||||
|
repeatMode={repeatMode}
|
||||||
|
position={position}
|
||||||
|
duration={duration}
|
||||||
|
onPlayPause={togglePlayPause}
|
||||||
|
onNext={nextTrack}
|
||||||
|
onPrev={prevTrack}
|
||||||
|
onShuffle={toggleShuffle}
|
||||||
|
onRepeat={toggleRepeat}
|
||||||
|
onSeek={seek}
|
||||||
|
onOpenPlaylist={openPlaylist}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: '播放',
|
||||||
|
tabBarIcon: ({ color, size }) => <PlayIconNav color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Playlist"
|
||||||
|
children={() => (
|
||||||
|
<PlaylistScreen
|
||||||
|
tracks={tracks}
|
||||||
|
cacheInfo={cacheInfo}
|
||||||
|
downloadingTrackSrcs={downloadingTrackSrcs}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onPlaybackOrderChange={handlePlaybackOrderChange}
|
||||||
|
onSelectTrack={(index) => loadTrack(index, true)}
|
||||||
|
onDownloadTrack={handleDownloadTrack}
|
||||||
|
onDownloadAll={downloadAllTracks}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
downloadProgress={downloadProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: '列表',
|
||||||
|
tabBarIcon: ({ color, size }) => <ListIconNav color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Settings"
|
||||||
|
children={() => (
|
||||||
|
<SettingsScreen
|
||||||
|
cacheOnly={cacheOnly}
|
||||||
|
resumePlaybackEnabled={resumePlaybackEnabled}
|
||||||
|
playlistFiles={playlistFiles}
|
||||||
|
currentPlaylistFile={currentPlaylistFile}
|
||||||
|
onCacheOnlyChange={handleCacheOnlyChange}
|
||||||
|
onResumePlaybackChange={handleResumePlaybackChange}
|
||||||
|
onCacheChanged={refreshCacheInfo}
|
||||||
|
onPlaylistChange={handlePlaylistChange}
|
||||||
|
cachedTrackCount={tracks.filter((track) => !!cacheInfo[track.src]).length}
|
||||||
|
trackCount={tracks.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: '设置',
|
||||||
|
tabBarIcon: ({ color, size }) => <SettingsIconNav color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||||
|
<NavigationContainer>
|
||||||
|
<TabNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingStyles = StyleSheet.create({
|
||||||
|
background: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: theme.spacing.xl,
|
||||||
|
backgroundColor: 'rgba(6,6,18,0.18)',
|
||||||
|
},
|
||||||
|
badgeWrap: {
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
backgroundColor: 'rgba(6,6,18,0.38)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.18)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
badgeImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#f3f1eb',
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'rgba(243,241,235,0.86)',
|
||||||
|
},
|
||||||
|
spinner: {
|
||||||
|
marginTop: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Track } from './types';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://47.101.56.101:8976';
|
||||||
|
const PLAYLIST_LIST_PATH = '/api/zen_box/playlists';
|
||||||
|
const DEFAULT_PLAYLIST_FILE = 'playlist.json';
|
||||||
|
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 api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatError = (error: any): string => {
|
||||||
|
if (error.response) {
|
||||||
|
return `服务器响应 ${error.response.status}: ${error.response.statusText}`;
|
||||||
|
} else if (error.request) {
|
||||||
|
return `服务器无响应: 无法连接到 ${API_BASE_URL}`;
|
||||||
|
} else if (error.message) {
|
||||||
|
return `请求错误: ${error.message}`;
|
||||||
|
} else {
|
||||||
|
return `未知错误`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePlaylistFile = (fileName?: string): string => {
|
||||||
|
return String(fileName || DEFAULT_PLAYLIST_FILE).trim() || DEFAULT_PLAYLIST_FILE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPlaylistPath = (fileName?: string): string => {
|
||||||
|
return `/tools/zen_box/${encodeURIComponent(normalizePlaylistFile(fileName))}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaylistSnapshotKey = (fileName?: string): string => {
|
||||||
|
return `${PLAYLIST_SNAPSHOT_KEY_PREFIX}${normalizePlaylistFile(fileName)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaybackPositionKey = (playlistFile?: string, trackSrc?: string): string => {
|
||||||
|
return `${PLAYBACK_POSITION_KEY_PREFIX}${normalizePlaylistFile(playlistFile)}::${String(trackSrc || '').trim()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlaylistFiles = async (): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
if (!files.includes(DEFAULT_PLAYLIST_FILE)) {
|
||||||
|
files.unshift(DEFAULT_PLAYLIST_FILE);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取歌单列表失败:', error);
|
||||||
|
return [DEFAULT_PLAYLIST_FILE];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSavedSelectedPlaylist = async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const value = await AsyncStorage.getItem(SELECTED_PLAYLIST_KEY);
|
||||||
|
return normalizePlaylistFile(value || DEFAULT_PLAYLIST_FILE);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取当前歌单失败:', error);
|
||||||
|
return DEFAULT_PLAYLIST_FILE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveSelectedPlaylist = async (fileName: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(SELECTED_PLAYLIST_KEY, normalizePlaylistFile(fileName));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存当前歌单失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getResumePlaybackEnabled = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const value = await AsyncStorage.getItem(RESUME_ENABLED_KEY);
|
||||||
|
if (value === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value === '1';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取断点续播设置失败:', error);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveResumePlaybackEnabled = async (enabled: boolean): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(RESUME_ENABLED_KEY, enabled ? '1' : '0');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存断点续播设置失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSavedPlaybackPosition = async (playlistFile?: string, trackSrc?: string): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const key = getPlaybackPositionKey(playlistFile, trackSrc);
|
||||||
|
const value = await AsyncStorage.getItem(key);
|
||||||
|
const seconds = Number(value || 0);
|
||||||
|
return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取播放进度失败:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const savePlaybackPosition = async (
|
||||||
|
playlistFile: string,
|
||||||
|
trackSrc: string,
|
||||||
|
positionSeconds: number
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const key = getPlaybackPositionKey(playlistFile, trackSrc);
|
||||||
|
const safeSeconds = Math.max(0, Math.floor(positionSeconds || 0));
|
||||||
|
await AsyncStorage.setItem(key, String(safeSeconds));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存播放进度失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearPlaybackPosition = async (playlistFile: string, trackSrc: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const key = getPlaybackPositionKey(playlistFile, trackSrc);
|
||||||
|
await AsyncStorage.removeItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清除播放进度失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlaylist = async (fileName?: string): Promise<Track[]> => {
|
||||||
|
try {
|
||||||
|
const response = await api.get<Track[]>(buildPlaylistPath(fileName));
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidPlaylist = (data: unknown): data is Track[] => {
|
||||||
|
return Array.isArray(data) && data.every((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const track = item as Track;
|
||||||
|
return typeof track.title === 'string'
|
||||||
|
&& typeof track.artist === 'string'
|
||||||
|
&& typeof track.src === 'string';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const savePlaylistSnapshot = async (tracks: Track[], fileName?: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(getPlaylistSnapshotKey(fileName), JSON.stringify(tracks));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存歌单快照失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlaylistSnapshot = async (fileName?: string): Promise<Track[]> => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(getPlaylistSnapshotKey(fileName));
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return isValidPlaylist(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取歌单快照失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlaylistWithFallback = async (fileName?: string): Promise<{
|
||||||
|
tracks: Track[];
|
||||||
|
source: 'remote' | 'snapshot';
|
||||||
|
fallbackReason?: string;
|
||||||
|
playlistFile: string;
|
||||||
|
}> => {
|
||||||
|
const normalizedFile = normalizePlaylistFile(fileName);
|
||||||
|
try {
|
||||||
|
const tracks = await getPlaylist(normalizedFile);
|
||||||
|
await savePlaylistSnapshot(tracks, normalizedFile);
|
||||||
|
return { tracks, source: 'remote', playlistFile: normalizedFile };
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackTracks = await getPlaylistSnapshot(normalizedFile);
|
||||||
|
if (fallbackTracks.length > 0) {
|
||||||
|
return {
|
||||||
|
tracks: fallbackTracks,
|
||||||
|
source: 'snapshot',
|
||||||
|
fallbackReason: error instanceof Error ? error.message : '远程歌单加载失败',
|
||||||
|
playlistFile: normalizedFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "观空禅音",
|
||||||
|
"slug": "zen-box",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#060612"
|
||||||
|
},
|
||||||
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"infoPlist": {
|
||||||
|
"UIBackgroundModes": [
|
||||||
|
"audio"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#060612"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-av",
|
||||||
|
"expo-splash-screen"
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "545555e0-1bf6-4f12-989a-8c772f3caffe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function(api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
LayoutChangeEvent,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextProps,
|
||||||
|
TextStyle,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
interface MarqueeTextProps extends Omit<TextProps, 'style'> {
|
||||||
|
text: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
style?: StyleProp<TextStyle>;
|
||||||
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
|
gap?: number;
|
||||||
|
speed?: number;
|
||||||
|
initialDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarqueeText: React.FC<MarqueeTextProps> = ({
|
||||||
|
text,
|
||||||
|
enabled = false,
|
||||||
|
style,
|
||||||
|
containerStyle,
|
||||||
|
gap = 40,
|
||||||
|
speed = 28,
|
||||||
|
initialDelayMs = 1200,
|
||||||
|
...textProps
|
||||||
|
}) => {
|
||||||
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const animationRef = useRef<Animated.CompositeAnimation | null>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
const [textWidth, setTextWidth] = useState(0);
|
||||||
|
|
||||||
|
const normalizedText = useMemo(() => String(text || ''), [text]);
|
||||||
|
const shouldAnimate = enabled && containerWidth > 0 && textWidth > containerWidth + 4;
|
||||||
|
const distance = Math.max(0, textWidth + gap);
|
||||||
|
const durationMs = Math.max(6000, Math.round((distance / Math.max(speed, 1)) * 1000));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
animationRef.current?.stop();
|
||||||
|
translateX.stopAnimation();
|
||||||
|
translateX.setValue(0);
|
||||||
|
|
||||||
|
if (!shouldAnimate || !distance) {
|
||||||
|
return () => {
|
||||||
|
animationRef.current?.stop();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequence = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.delay(initialDelayMs),
|
||||||
|
Animated.timing(translateX, {
|
||||||
|
toValue: -distance,
|
||||||
|
duration: durationMs,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.delay(700),
|
||||||
|
Animated.timing(translateX, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
animationRef.current = sequence;
|
||||||
|
sequence.start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sequence.stop();
|
||||||
|
animationRef.current = null;
|
||||||
|
translateX.stopAnimation();
|
||||||
|
translateX.setValue(0);
|
||||||
|
};
|
||||||
|
}, [distance, durationMs, initialDelayMs, shouldAnimate, translateX]);
|
||||||
|
|
||||||
|
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
||||||
|
const nextWidth = Math.round(event.nativeEvent.layout.width || 0);
|
||||||
|
if (nextWidth !== containerWidth) {
|
||||||
|
setContainerWidth(nextWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextLayout = (event: LayoutChangeEvent) => {
|
||||||
|
const nextWidth = Math.round(event.nativeEvent.layout.width || 0);
|
||||||
|
if (nextWidth !== textWidth) {
|
||||||
|
setTextWidth(nextWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, containerStyle]} onLayout={handleContainerLayout}>
|
||||||
|
<Text
|
||||||
|
{...textProps}
|
||||||
|
style={[style, styles.measureText]}
|
||||||
|
onLayout={handleTextLayout}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{normalizedText}
|
||||||
|
</Text>
|
||||||
|
{shouldAnimate ? (
|
||||||
|
<Animated.View style={[styles.track, { transform: [{ translateX }] }]}>
|
||||||
|
<Text {...textProps} style={style} numberOfLines={1}>
|
||||||
|
{normalizedText}
|
||||||
|
</Text>
|
||||||
|
<View style={{ width: gap }} />
|
||||||
|
<Text {...textProps} style={style} numberOfLines={1}>
|
||||||
|
{normalizedText}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
) : (
|
||||||
|
<Text {...textProps} style={style} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{normalizedText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
measureText: {
|
||||||
|
position: 'absolute',
|
||||||
|
opacity: 0,
|
||||||
|
left: 0,
|
||||||
|
top: -9999,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet, PanResponder } from 'react-native';
|
||||||
|
import Svg, { Circle, Path, Polyline, Text as SvgText } from 'react-native-svg';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
import { RepeatMode } from '../types';
|
||||||
|
|
||||||
|
interface PlayerControlsProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
isShuffle: boolean;
|
||||||
|
repeatMode: RepeatMode;
|
||||||
|
position: number;
|
||||||
|
duration: number;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onShuffle: () => void;
|
||||||
|
onRepeat: () => void;
|
||||||
|
onSeek: (position: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
if (isNaN(seconds) || seconds < 0) return '0:00';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlayIcon = ({ size = 24 }: { size?: number }) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.background}>
|
||||||
|
<Path d="M8 5v14l11-7z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PauseIcon = ({ size = 24 }: { size?: number }) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.background}>
|
||||||
|
<Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NextIcon = ({ size = 22 }: { size?: number }) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.textSecondary}>
|
||||||
|
<Path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PrevIcon = ({ size = 22 }: { size?: number }) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.textSecondary}>
|
||||||
|
<Path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ShuffleIcon = ({ active = false }: { active?: boolean }) => (
|
||||||
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={active ? theme.colors.primary : theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Polyline points="16 1 21 3 21 8" />
|
||||||
|
<Path d="M4 4 C8 4 10 8 12 12 C14 16 16 20 20 20" />
|
||||||
|
<Polyline points="16 23 21 21 21 18" />
|
||||||
|
<Path d="M4 20 C8 20 10 16 12 12 C14 8 16 4 20 4" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RepeatIcon = ({ mode }: { mode: RepeatMode }) => {
|
||||||
|
const active = mode !== 'none';
|
||||||
|
const color = active ? theme.colors.primary : theme.colors.textSecondary;
|
||||||
|
const badgeFill = mode === 'none' ? 'transparent' : theme.colors.primary;
|
||||||
|
const badgeStroke = mode === 'none' ? 'transparent' : theme.colors.background;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Polyline points="17 1 21 5 17 9" />
|
||||||
|
<Path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||||
|
<Polyline points="7 23 3 19 7 15" />
|
||||||
|
<Path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||||
|
{mode !== 'none' ? <Circle cx="18" cy="18" r="4.4" fill={badgeFill} stroke={badgeStroke} strokeWidth={1.2} /> : null}
|
||||||
|
{mode === 'one' ? (
|
||||||
|
<SvgText
|
||||||
|
x="18"
|
||||||
|
y="19.4"
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="6.5"
|
||||||
|
fontWeight="700"
|
||||||
|
fill={theme.colors.background}
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</SvgText>
|
||||||
|
) : null}
|
||||||
|
{mode === 'all' ? (
|
||||||
|
<>
|
||||||
|
<Circle cx="16.4" cy="18" r="0.9" fill={theme.colors.background} stroke="none" />
|
||||||
|
<Circle cx="19.6" cy="18" r="0.9" fill={theme.colors.background} stroke="none" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
|
isPlaying,
|
||||||
|
isShuffle,
|
||||||
|
repeatMode,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
onPlayPause,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onShuffle,
|
||||||
|
onRepeat,
|
||||||
|
onSeek,
|
||||||
|
}) => {
|
||||||
|
const progress = duration > 0 ? position / duration : 0;
|
||||||
|
const progressBarRef = React.useRef<View>(null);
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const [dragProgress, setDragProgress] = React.useState(progress);
|
||||||
|
|
||||||
|
const handleSeekTo = (newProgress: number) => {
|
||||||
|
if (duration > 0) {
|
||||||
|
onSeek(newProgress * duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const panResponder = React.useMemo(
|
||||||
|
() =>
|
||||||
|
PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onStartShouldSetPanResponderCapture: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponderCapture: () => true,
|
||||||
|
|
||||||
|
onPanResponderGrant: () => {
|
||||||
|
setIsDragging(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
onPanResponderMove: (evt) => {
|
||||||
|
// 测量进度条宽度
|
||||||
|
progressBarRef.current?.measure((x, y, width) => {
|
||||||
|
if (width > 0) {
|
||||||
|
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
||||||
|
setDragProgress(newProgress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onPanResponderRelease: (evt) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
progressBarRef.current?.measure((x, y, width) => {
|
||||||
|
if (width > 0) {
|
||||||
|
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
||||||
|
handleSeekTo(newProgress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onPanResponderTerminate: (evt) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
progressBarRef.current?.measure((x, y, width) => {
|
||||||
|
if (width > 0) {
|
||||||
|
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
||||||
|
handleSeekTo(newProgress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[duration]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentProgress = isDragging ? dragProgress : progress;
|
||||||
|
const thumbPosition = currentProgress * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.glassBar}>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<View style={styles.progressWrap}>
|
||||||
|
<View style={styles.timeRow}>
|
||||||
|
<Text style={styles.timeText}>{formatTime(position)}</Text>
|
||||||
|
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
ref={progressBarRef}
|
||||||
|
style={styles.progressTrack}
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
>
|
||||||
|
<View style={[styles.progressFill, { width: `${currentProgress * 100}%` }]} />
|
||||||
|
<View style={[styles.progressThumb, { left: `${thumbPosition}%`, transform: [{ translateX: -7 }] }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main controls */}
|
||||||
|
<View style={styles.controls}>
|
||||||
|
<TouchableOpacity style={styles.ctrlBtn} onPress={onShuffle}>
|
||||||
|
<ShuffleIcon active={isShuffle} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onPrev()}>
|
||||||
|
<PrevIcon />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.playBtn} onPress={() => onPlayPause()}>
|
||||||
|
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onNext()}>
|
||||||
|
<NextIcon />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onRepeat()}>
|
||||||
|
<RepeatIcon mode={repeatMode} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
glassBar: {
|
||||||
|
backgroundColor: 'rgba(10,10,30,0.65)',
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
},
|
||||||
|
progressWrap: {
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
progressTrack: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
|
borderRadius: 2,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
progressThumb: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -5,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
shadowColor: theme.colors.primary,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
timeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
ctrlBtn: {
|
||||||
|
padding: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
playBtn: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { View, StyleSheet, Dimensions } from 'react-native';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface Star {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
isGold: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateStars = (count: number, size: number, isGold: boolean = false): Star[] => {
|
||||||
|
const stars: Star[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
stars.push({
|
||||||
|
x: Math.random() * SCREEN_WIDTH,
|
||||||
|
y: Math.random() * SCREEN_HEIGHT,
|
||||||
|
size,
|
||||||
|
isGold,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return stars;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StarComponent: React.FC<Star> = ({ x, y, size, isGold }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.star,
|
||||||
|
{
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: isGold ? theme.colors.primary : theme.colors.text,
|
||||||
|
opacity: 0.6 + Math.random() * 0.4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Starfield: React.FC = () => {
|
||||||
|
const smallStars = useMemo(() => generateStars(30, 1), []);
|
||||||
|
const mediumStars = useMemo(() => generateStars(15, 1.5), []);
|
||||||
|
const largeStars = useMemo(() => generateStars(8, 2, true), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Radial gradient background simulation */}
|
||||||
|
<View style={styles.radialGlow} />
|
||||||
|
|
||||||
|
{/* Stars */}
|
||||||
|
{smallStars.map((star, i) => (
|
||||||
|
<StarComponent key={`small-${i}`} {...star} />
|
||||||
|
))}
|
||||||
|
{mediumStars.map((star, i) => (
|
||||||
|
<StarComponent key={`medium-${i}`} {...star} />
|
||||||
|
))}
|
||||||
|
{largeStars.map((star, i) => (
|
||||||
|
<StarComponent key={`large-${i}`} {...star} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
radialGlow: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: SCREEN_HEIGHT * 0.2,
|
||||||
|
left: SCREEN_WIDTH / 2 - 150,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
borderRadius: 150,
|
||||||
|
backgroundColor: theme.colors.primarySoft,
|
||||||
|
},
|
||||||
|
star: {
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Animated, Easing, View, StyleSheet } from 'react-native';
|
||||||
|
import Svg, { Path, Ellipse, Circle } from 'react-native-svg';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
|
||||||
|
interface ZenCenterProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZenFigure = () => (
|
||||||
|
<View style={styles.zenFigure}>
|
||||||
|
<Svg viewBox="0 0 200 210" style={styles.zenSvg}>
|
||||||
|
{/* Ground shadow */}
|
||||||
|
<Ellipse cx="100" cy="178" rx="44" ry="5.5" fill="rgba(232,184,109,0.08)" />
|
||||||
|
|
||||||
|
{/* Lower body - Full lotus base */}
|
||||||
|
<Ellipse cx="100" cy="158" rx="46" ry="14" fill="#c8973e" opacity={0.7} />
|
||||||
|
|
||||||
|
{/* Left knee */}
|
||||||
|
<Path
|
||||||
|
d="M 54 150 C 44 152, 36 158, 36 165 C 36 171, 42 175, 52 174 C 60 173, 68 167, 72 160 C 74 155, 70 149, 64 148 Z"
|
||||||
|
fill="#d4a045"
|
||||||
|
opacity={0.78}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right knee */}
|
||||||
|
<Path
|
||||||
|
d="M 146 150 C 156 152, 164 158, 164 165 C 164 171, 158 175, 148 174 C 140 173, 132 167, 128 160 C 126 155, 130 149, 136 148 Z"
|
||||||
|
fill="#d4a045"
|
||||||
|
opacity={0.78}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Left foot */}
|
||||||
|
<Path
|
||||||
|
d="M 124 148 C 132 144, 142 145, 146 150 C 148 153, 145 157, 138 157 C 131 157, 124 153, 122 150 Z"
|
||||||
|
fill="#c8973e"
|
||||||
|
opacity={0.72}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right foot */}
|
||||||
|
<Path
|
||||||
|
d="M 76 148 C 68 144, 58 145, 54 150 C 52 153, 55 157, 62 157 C 69 157, 76 153, 78 150 Z"
|
||||||
|
fill="#c8973e"
|
||||||
|
opacity={0.72}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Torso */}
|
||||||
|
<Path
|
||||||
|
d="M 72 152 C 70 140, 69 126, 70 114 C 71 102, 75 92, 82 85 C 88 79, 94 77, 100 76 C 106 77, 112 79, 118 85 C 125 92, 129 102, 130 114 C 131 126, 130 140, 128 152 C 120 157, 110 160, 100 160 C 90 160, 80 157, 72 152 Z"
|
||||||
|
fill="#e0a84e"
|
||||||
|
opacity={0.88}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Chest line */}
|
||||||
|
<Path
|
||||||
|
d="M 100 88 C 99 108, 99 130, 100 152"
|
||||||
|
stroke="#c8882a"
|
||||||
|
strokeWidth={0.8}
|
||||||
|
opacity={0.18}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Left upper arm */}
|
||||||
|
<Path
|
||||||
|
d="M 70 114 C 64 116, 58 122, 56 130 C 54 136, 56 142, 62 146 C 68 148, 75 146, 78 140 C 74 136, 70 128, 70 118 Z"
|
||||||
|
fill="#d4a045"
|
||||||
|
opacity={0.82}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right upper arm */}
|
||||||
|
<Path
|
||||||
|
d="M 130 114 C 136 116, 142 122, 144 130 C 146 136, 144 142, 138 146 C 132 148, 125 146, 122 140 C 126 136, 130 128, 130 118 Z"
|
||||||
|
fill="#d4a045"
|
||||||
|
opacity={0.82}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Left hand */}
|
||||||
|
<Path
|
||||||
|
d="M 84 154 C 80 152, 76 152, 75 155 C 74 158, 76 162, 82 163 C 88 164, 96 162, 98 158 C 98 155, 92 154, 84 154 Z"
|
||||||
|
fill="#d4a045"
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right hand */}
|
||||||
|
<Path
|
||||||
|
d="M 102 152 C 98 150, 94 150, 92 153 C 90 156, 91 160, 96 162 C 102 163, 110 161, 114 157 C 116 154, 112 151, 106 151 Z"
|
||||||
|
fill="#c8973e"
|
||||||
|
opacity={0.78}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Thumb tips touching */}
|
||||||
|
<Ellipse cx="99" cy="153" rx="4" ry="2.5" fill="#e8b86d" opacity={0.55} />
|
||||||
|
|
||||||
|
{/* Neck */}
|
||||||
|
<Path
|
||||||
|
d="M 93 78 C 93 74, 96 70, 100 69 C 104 70, 107 74, 107 78 L 107 86 L 93 86 Z"
|
||||||
|
fill="#e0a84e"
|
||||||
|
opacity={0.88}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Head */}
|
||||||
|
<Path
|
||||||
|
d="M 82 54 C 82 36, 90 26, 100 25 C 110 26, 118 36, 118 54 C 118 66, 112 74, 106 77 L 100 79 L 94 77 C 88 74, 82 66, 82 54 Z"
|
||||||
|
fill="#e0a84e"
|
||||||
|
opacity={0.88}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hair */}
|
||||||
|
<Path
|
||||||
|
d="M 82 50 C 82 36, 90 26, 100 25 C 110 26, 118 36, 118 50 C 112 43, 106 39, 100 38 C 94 39, 88 43, 82 50 Z"
|
||||||
|
fill="#b8812a"
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Face features */}
|
||||||
|
{/* Left brow */}
|
||||||
|
<Path
|
||||||
|
d="M 89 53 Q 93 51, 97 53"
|
||||||
|
stroke="#8a6020"
|
||||||
|
strokeWidth={0.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.35}
|
||||||
|
/>
|
||||||
|
{/* Right brow */}
|
||||||
|
<Path
|
||||||
|
d="M 103 53 Q 107 51, 111 53"
|
||||||
|
stroke="#8a6020"
|
||||||
|
strokeWidth={0.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.35}
|
||||||
|
/>
|
||||||
|
{/* Left eye closed */}
|
||||||
|
<Path
|
||||||
|
d="M 89 57 Q 93 61, 97 57"
|
||||||
|
stroke="#7a5010"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.65}
|
||||||
|
/>
|
||||||
|
{/* Right eye closed */}
|
||||||
|
<Path
|
||||||
|
d="M 103 57 Q 107 61, 111 57"
|
||||||
|
stroke="#7a5010"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.65}
|
||||||
|
/>
|
||||||
|
{/* Nose */}
|
||||||
|
<Path
|
||||||
|
d="M 100 62 C 99 65, 97 67, 97 68"
|
||||||
|
stroke="#8a6020"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.25}
|
||||||
|
/>
|
||||||
|
{/* Mouth - serene half smile */}
|
||||||
|
<Path
|
||||||
|
d="M 95 72 Q 100 75, 105 72"
|
||||||
|
stroke="#8a6020"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Crown chakra */}
|
||||||
|
<Circle cx="100" cy="24" r="3.5" fill="rgba(232,184,109,0.2)" />
|
||||||
|
<Circle cx="100" cy="24" r="1.8" fill="#e8b86d" opacity={0.75} />
|
||||||
|
<Circle cx="100" cy="24" r="0.8" fill="#fff" opacity={0.6} />
|
||||||
|
|
||||||
|
{/* Subtle inner light */}
|
||||||
|
<Circle cx="100" cy="110" r="12" fill="rgba(232,184,109,0.04)" />
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ZenCenter: React.FC<ZenCenterProps> = ({ isPlaying }) => {
|
||||||
|
const orbitAnimPrimary = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const orbitAnimSecondary = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const starBreathPrimary = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const starBreathSecondary = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const solarHaloAnim = React.useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
orbitAnimPrimary.setValue(0);
|
||||||
|
orbitAnimSecondary.setValue(0);
|
||||||
|
starBreathPrimary.setValue(0);
|
||||||
|
starBreathSecondary.setValue(0);
|
||||||
|
solarHaloAnim.setValue(0);
|
||||||
|
|
||||||
|
const primaryLoop = Animated.loop(
|
||||||
|
Animated.timing(orbitAnimPrimary, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 18000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondaryLoop = Animated.loop(
|
||||||
|
Animated.timing(orbitAnimSecondary, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 26000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryBreathLoop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(starBreathPrimary, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 4200,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(starBreathPrimary, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 4200,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondaryBreathLoop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(starBreathSecondary, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 6100,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(starBreathSecondary, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 6100,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const solarHaloLoop = Animated.loop(
|
||||||
|
Animated.timing(solarHaloAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 210000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
primaryLoop.start();
|
||||||
|
secondaryLoop.start();
|
||||||
|
primaryBreathLoop.start();
|
||||||
|
secondaryBreathLoop.start();
|
||||||
|
solarHaloLoop.start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
primaryLoop.stop();
|
||||||
|
secondaryLoop.stop();
|
||||||
|
primaryBreathLoop.stop();
|
||||||
|
secondaryBreathLoop.stop();
|
||||||
|
solarHaloLoop.stop();
|
||||||
|
};
|
||||||
|
}, [orbitAnimPrimary, orbitAnimSecondary, solarHaloAnim, starBreathPrimary, starBreathSecondary]);
|
||||||
|
|
||||||
|
const primaryRotate = orbitAnimPrimary.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0deg', '360deg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondaryRotate = orbitAnimSecondary.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0deg', '-360deg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryStarOpacity = starBreathPrimary.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.45, 0.9],
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryStarScale = starBreathPrimary.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.92, 1.14],
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondaryStarOpacity = starBreathSecondary.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.16, 0.55],
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondaryStarScale = starBreathSecondary.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.9, 1.2],
|
||||||
|
});
|
||||||
|
|
||||||
|
const haloRotate = solarHaloAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0deg', '360deg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const haloOpacity = solarHaloAnim.interpolate({
|
||||||
|
inputRange: [0, 0.12, 0.18, 0.26, 0.62, 0.72, 0.8, 1],
|
||||||
|
outputRange: [0.02, 0.03, 0.12, 0.04, 0.03, 0.11, 0.04, 0.02],
|
||||||
|
});
|
||||||
|
|
||||||
|
const haloScale = solarHaloAnim.interpolate({
|
||||||
|
inputRange: [0, 0.18, 0.62, 0.72, 1],
|
||||||
|
outputRange: [0.88, 1.06, 0.9, 1.08, 0.88],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.orbitWrap}>
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
styles.solarHaloTrack,
|
||||||
|
{
|
||||||
|
opacity: haloOpacity,
|
||||||
|
transform: [{ rotate: haloRotate }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.solarHalo,
|
||||||
|
{
|
||||||
|
transform: [{ scale: haloScale }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.solarCore} />
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[styles.orbitLayerPrimary, { transform: [{ rotate: primaryRotate }] }]}
|
||||||
|
>
|
||||||
|
<View style={styles.orbitRing} />
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.orbitDot1,
|
||||||
|
{
|
||||||
|
opacity: primaryStarOpacity,
|
||||||
|
transform: [{ scale: primaryStarScale }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.orbitDot1Trail,
|
||||||
|
{
|
||||||
|
opacity: secondaryStarOpacity,
|
||||||
|
transform: [{ scale: secondaryStarScale }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[styles.orbitLayerSecondary, { transform: [{ rotate: secondaryRotate }] }]}
|
||||||
|
>
|
||||||
|
<View style={styles.orbitRing2} />
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.orbitDot2,
|
||||||
|
{
|
||||||
|
opacity: secondaryStarOpacity,
|
||||||
|
transform: [{ scale: secondaryStarScale }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.orbitDot3,
|
||||||
|
{
|
||||||
|
opacity: primaryStarOpacity,
|
||||||
|
transform: [{ scale: primaryStarScale }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Zen glow */}
|
||||||
|
<View style={[styles.zenGlow, { opacity: isPlaying ? 1 : 0.55 }]} />
|
||||||
|
|
||||||
|
{/* Zen waves */}
|
||||||
|
<View style={styles.zenWaves}>
|
||||||
|
<View style={styles.zenWave} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Zen figure */}
|
||||||
|
<ZenFigure />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
orbitWrap: {
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
orbitLayerPrimary: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
orbitLayerSecondary: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 256,
|
||||||
|
height: 256,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
solarHaloTrack: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 320,
|
||||||
|
height: 320,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
solarHalo: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -18,
|
||||||
|
right: 24,
|
||||||
|
width: 92,
|
||||||
|
height: 92,
|
||||||
|
borderRadius: 46,
|
||||||
|
backgroundColor: 'rgba(232,184,109,0.14)',
|
||||||
|
shadowColor: theme.colors.primary,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
solarCore: {
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 13,
|
||||||
|
backgroundColor: 'rgba(255,244,214,0.28)',
|
||||||
|
},
|
||||||
|
orbitRing: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
borderRadius: 110,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(232,184,109,0.12)',
|
||||||
|
},
|
||||||
|
orbitRing2: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 256,
|
||||||
|
height: 256,
|
||||||
|
borderRadius: 128,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(232,184,109,0.06)',
|
||||||
|
},
|
||||||
|
orbitDot1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -3,
|
||||||
|
left: 107,
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
opacity: 0.55,
|
||||||
|
},
|
||||||
|
orbitDot1Trail: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 26,
|
||||||
|
right: 30,
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
opacity: 0.22,
|
||||||
|
},
|
||||||
|
orbitDot2: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -2,
|
||||||
|
left: 127,
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
orbitDot3: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 36,
|
||||||
|
left: 18,
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
opacity: 0.18,
|
||||||
|
},
|
||||||
|
zenGlow: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: 80,
|
||||||
|
backgroundColor: theme.colors.primarySoft,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
zenWaves: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
zenWave: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: 80,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(232,184,109,0.08)',
|
||||||
|
},
|
||||||
|
zenFigure: {
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
},
|
||||||
|
zenSvg: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "app-zen-box",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "node_modules/expo/AppEntry.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||||
|
"@react-navigation/native": "^6.1.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"expo": "~54.0.0",
|
||||||
|
"expo-av": "~16.0.8",
|
||||||
|
"expo-file-system": "~17.0.1",
|
||||||
|
"expo-splash-screen": "~31.0.10",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "15.2.0",
|
||||||
|
"react-native-worklets": "^0.10.0",
|
||||||
|
"react-native-worklets-core": "^1.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.0",
|
||||||
|
"@types/react": "~19.1.10",
|
||||||
|
"@types/react-native": "~0.73.0",
|
||||||
|
"babel-preset-expo": "~54.0.10",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import { Starfield } from '../components/Starfield';
|
||||||
|
import { ZenCenter } from '../components/ZenCenter';
|
||||||
|
import { MarqueeText } from '../components/MarqueeText';
|
||||||
|
import { PlayerControls } from '../components/PlayerControls';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
import { Track, RepeatMode } from '../types';
|
||||||
|
|
||||||
|
interface PlayerScreenProps {
|
||||||
|
currentTrack: Track | null;
|
||||||
|
trackCount: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isShuffle: boolean;
|
||||||
|
repeatMode: RepeatMode;
|
||||||
|
position: number;
|
||||||
|
duration: number;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onShuffle: () => void;
|
||||||
|
onRepeat: () => void;
|
||||||
|
onSeek: (position: number) => void;
|
||||||
|
onOpenPlaylist: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListIcon = () => (
|
||||||
|
<Svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round">
|
||||||
|
<Path d="M8 6h13M8 12h13M8 18h13" />
|
||||||
|
<Path d="M3 6h.01M3 12h.01M3 18h.01" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlayerScreen: React.FC<PlayerScreenProps> = ({
|
||||||
|
currentTrack,
|
||||||
|
trackCount,
|
||||||
|
isPlaying,
|
||||||
|
isShuffle,
|
||||||
|
repeatMode,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
onPlayPause,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onShuffle,
|
||||||
|
onRepeat,
|
||||||
|
onSeek,
|
||||||
|
onOpenPlaylist,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Starfield background */}
|
||||||
|
<Starfield />
|
||||||
|
|
||||||
|
<SafeAreaView style={styles.safeContainer} edges={['top', 'bottom']}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>观空禅音</Text>
|
||||||
|
<Text style={styles.headerCount}>{trackCount} 首</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Zen center */}
|
||||||
|
<View style={styles.zenCenterContainer}>
|
||||||
|
<ZenCenter isPlaying={isPlaying} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<View style={styles.trackInfo}>
|
||||||
|
<MarqueeText
|
||||||
|
text={currentTrack?.title || '未选择歌曲'}
|
||||||
|
enabled={!!currentTrack?.title}
|
||||||
|
style={styles.trackTitle}
|
||||||
|
containerStyle={styles.trackTitleWrap}
|
||||||
|
/>
|
||||||
|
<Text style={styles.trackArtist} numberOfLines={1}>
|
||||||
|
{currentTrack?.artist || '—'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
|
||||||
|
{/* Player controls */}
|
||||||
|
<PlayerControls
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isShuffle={isShuffle}
|
||||||
|
repeatMode={repeatMode}
|
||||||
|
position={position}
|
||||||
|
duration={duration}
|
||||||
|
onPlayPause={onPlayPause}
|
||||||
|
onNext={onNext}
|
||||||
|
onPrev={onPrev}
|
||||||
|
onShuffle={onShuffle}
|
||||||
|
onRepeat={onRepeat}
|
||||||
|
onSeek={onSeek}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playlist button */}
|
||||||
|
<View style={styles.playlistButtonContainer}>
|
||||||
|
<TouchableOpacity style={styles.playlistButton} onPress={onOpenPlaylist}>
|
||||||
|
<ListIcon />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
safeContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.06,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
headerCount: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
zenCenterContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
trackInfo: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
},
|
||||||
|
trackTitleWrap: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
trackTitle: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
trackArtist: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
height: theme.spacing.md,
|
||||||
|
},
|
||||||
|
playlistButtonContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
playlistButton: {
|
||||||
|
padding: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator, Animated } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
import { MarqueeText } from '../components/MarqueeText';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
import { Track, CacheInfo } from '../types';
|
||||||
|
import * as Cache from '../utils/cache';
|
||||||
|
|
||||||
|
const SORT_PREF_KEY = '@zen_box_sort_pref';
|
||||||
|
type SortMode = 'original' | 'asc' | 'desc';
|
||||||
|
|
||||||
|
interface PlaylistScreenProps {
|
||||||
|
tracks: Track[];
|
||||||
|
cacheInfo: CacheInfo;
|
||||||
|
downloadingTrackSrcs: string[];
|
||||||
|
currentIndex: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
onPlaybackOrderChange: (order: number[]) => void;
|
||||||
|
onSelectTrack: (index: number) => void;
|
||||||
|
onDownloadTrack: (track: Track) => Promise<void>;
|
||||||
|
onDownloadAll?: () => void;
|
||||||
|
isDownloading?: boolean;
|
||||||
|
downloadProgress?: { completed: number; total: number; title: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayingIndicator = () => {
|
||||||
|
const anim1 = useRef(new Animated.Value(0.3)).current;
|
||||||
|
const anim2 = useRef(new Animated.Value(0.7)).current;
|
||||||
|
const anim3 = useRef(new Animated.Value(0.5)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = (value: Animated.Value, delay: number) => {
|
||||||
|
Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(value, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 400,
|
||||||
|
delay: delay,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(value, {
|
||||||
|
toValue: 0.3,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
animate(anim1, 0);
|
||||||
|
animate(anim2, 200);
|
||||||
|
animate(anim3, 400);
|
||||||
|
}, [anim1, anim2, anim3]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.playingIndicator}>
|
||||||
|
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim1 }] }]} />
|
||||||
|
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim2 }] }]} />
|
||||||
|
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim3 }] }]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DownloadIcon = () => (
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.primary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<Path d="M7 10l5 5 5-5" />
|
||||||
|
<Path d="M12 15V3" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.primary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M20 6L9 17l-5-5" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ArrowUpIcon = () => (
|
||||||
|
<Svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke={theme.colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M12 19V5M5 12l7-7 7 7" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ArrowDownIcon = () => (
|
||||||
|
<Svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke={theme.colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M12 5v14M5 12l7 7 7-7" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrackItem: React.FC<{
|
||||||
|
track: Track;
|
||||||
|
index: number;
|
||||||
|
isCurrent: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isCached: boolean;
|
||||||
|
isDownloading: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
}> = ({ track, index, isCurrent, isPlaying, isCached, isDownloading, onPress, onDownload }) => (
|
||||||
|
<TouchableOpacity style={styles.trackItem} onPress={onPress}>
|
||||||
|
<View style={styles.trackNumber}>
|
||||||
|
{isCurrent && isPlaying ? (
|
||||||
|
<PlayingIndicator />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.trackNumberText, isCurrent && styles.trackNumberActive]}>
|
||||||
|
{index + 1}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.trackInfo}>
|
||||||
|
<MarqueeText
|
||||||
|
text={track.title}
|
||||||
|
enabled={isCurrent}
|
||||||
|
style={[styles.trackTitle, isCurrent && styles.trackTitleActive]}
|
||||||
|
containerStyle={styles.trackTitleWrap}
|
||||||
|
/>
|
||||||
|
<Text style={styles.trackArtist} numberOfLines={1}>
|
||||||
|
{track.artist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.downloadBtn, isDownloading && styles.downloadBtnDisabled]}
|
||||||
|
onPress={onDownload}
|
||||||
|
disabled={isCached || isDownloading}
|
||||||
|
>
|
||||||
|
{isCached ? (
|
||||||
|
<CheckIcon />
|
||||||
|
) : isDownloading ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<DownloadIcon />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
||||||
|
tracks,
|
||||||
|
cacheInfo,
|
||||||
|
downloadingTrackSrcs,
|
||||||
|
currentIndex,
|
||||||
|
isPlaying,
|
||||||
|
onPlaybackOrderChange,
|
||||||
|
onSelectTrack,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadAll,
|
||||||
|
isDownloading = false,
|
||||||
|
downloadProgress,
|
||||||
|
}) => {
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>('original');
|
||||||
|
const [sortedTracks, setSortedTracks] = useState<Track[]>(tracks);
|
||||||
|
const [originalTracks, setOriginalTracks] = useState<Track[]>(tracks);
|
||||||
|
|
||||||
|
// 加载排序偏好
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSortPref = async () => {
|
||||||
|
try {
|
||||||
|
const saved = await AsyncStorage.getItem(SORT_PREF_KEY);
|
||||||
|
if (saved && ['original', 'asc', 'desc'].includes(saved)) {
|
||||||
|
setSortMode(saved as SortMode);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载排序偏好失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSortPref();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 保存排序偏好
|
||||||
|
const saveSortPref = async (newMode: SortMode) => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(SORT_PREF_KEY, newMode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存排序偏好失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排序函数
|
||||||
|
useEffect(() => {
|
||||||
|
let result = [...tracks];
|
||||||
|
setOriginalTracks(tracks);
|
||||||
|
|
||||||
|
if (sortMode === 'asc') {
|
||||||
|
result.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
|
||||||
|
} else if (sortMode === 'desc') {
|
||||||
|
result.sort((a, b) => b.title.localeCompare(a.title, 'zh-CN'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortedTracks(result);
|
||||||
|
}, [tracks, sortMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const order = sortedTracks
|
||||||
|
.map((track) => originalTracks.findIndex((item) => item.src === track.src))
|
||||||
|
.filter((index) => index >= 0);
|
||||||
|
|
||||||
|
if (order.length === originalTracks.length) {
|
||||||
|
onPlaybackOrderChange(order);
|
||||||
|
}
|
||||||
|
}, [sortedTracks, originalTracks, onPlaybackOrderChange]);
|
||||||
|
|
||||||
|
const handleDownloadTrack = async (track: Track) => {
|
||||||
|
try {
|
||||||
|
await onDownloadTrack(track);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('下载失败', '无法下载此歌曲');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAll = async () => {
|
||||||
|
if (!onDownloadAll) {
|
||||||
|
try {
|
||||||
|
await Cache.downloadAllTracks(tracks, (current, total, title) => {
|
||||||
|
console.log(`Downloading ${current + 1}/${total}: ${title}`);
|
||||||
|
});
|
||||||
|
Alert.alert('完成', '所有歌曲已缓存');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('下载失败', '部分歌曲下载失败');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDownloadAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSort = (newMode: SortMode) => {
|
||||||
|
setSortMode(newMode);
|
||||||
|
saveSortPref(newMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedCount = tracks.filter((track) => !!cacheInfo[track.src]).length;
|
||||||
|
const progressPercent =
|
||||||
|
isDownloading && downloadProgress && downloadProgress.total > 0
|
||||||
|
? Math.min(100, Math.round((downloadProgress.completed / downloadProgress.total) * 100))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 获取原始索引用于播放
|
||||||
|
const getOriginalIndex = (sortedIndex: number) => {
|
||||||
|
const track = sortedTracks[sortedIndex];
|
||||||
|
return originalTracks.findIndex(t => t.src === track.src);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<Text style={styles.headerTitle}>播放列表</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
{cachedCount}/{tracks.length} 首已缓存
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isDownloading && downloadProgress && downloadProgress.total > 0 && (
|
||||||
|
<View style={styles.progressSection}>
|
||||||
|
<View style={styles.progressTextRow}>
|
||||||
|
<Text style={styles.progressText} numberOfLines={1}>
|
||||||
|
{downloadProgress.title || '正在下载...'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.progressPercent}>{progressPercent}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
{progressPercent !== null && (
|
||||||
|
<View
|
||||||
|
style={[styles.progressFill, { width: `${progressPercent}%` }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.progressCount}>
|
||||||
|
{downloadProgress.completed}/{downloadProgress.total}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={sortedTracks}
|
||||||
|
keyExtractor={(item) => item.src}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackItem
|
||||||
|
track={item}
|
||||||
|
index={index}
|
||||||
|
isCurrent={getOriginalIndex(index) === currentIndex}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isCached={!!cacheInfo[item.src]}
|
||||||
|
isDownloading={downloadingTrackSrcs.includes(item.src)}
|
||||||
|
onPress={() => onSelectTrack(getOriginalIndex(index))}
|
||||||
|
onDownload={() => handleDownloadTrack(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 底部操作栏 */}
|
||||||
|
<View style={styles.bottomActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionBtn, sortMode === 'asc' && styles.actionBtnActive]}
|
||||||
|
onPress={() => setSort('asc')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.actionBtnText, sortMode === 'asc' && styles.actionBtnTextActive]}>
|
||||||
|
📋 名称正序
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionBtn, sortMode === 'desc' && styles.actionBtnActive]}
|
||||||
|
onPress={() => setSort('desc')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.actionBtnText, sortMode === 'desc' && styles.actionBtnTextActive]}>
|
||||||
|
📋 名称倒序
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionBtn, styles.downloadAllBtn]}
|
||||||
|
onPress={handleDownloadAll}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.downloadAllText}>⬇️ 一键下载所有</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
progressSection: {
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
progressTextRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
progressPercent: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: theme.colors.primary,
|
||||||
|
marginLeft: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
progressCount: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingBottom: theme.spacing.xl,
|
||||||
|
},
|
||||||
|
trackItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
trackNumber: {
|
||||||
|
width: 24,
|
||||||
|
marginRight: theme.spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
trackNumberText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
},
|
||||||
|
trackNumberActive: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
trackInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: theme.spacing.sm,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
trackTitleWrap: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
trackTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
trackTitleActive: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
trackArtist: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
downloadBtn: {
|
||||||
|
padding: theme.spacing.xs,
|
||||||
|
width: 28,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
downloadBtnDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: theme.colors.border,
|
||||||
|
marginLeft: 40,
|
||||||
|
},
|
||||||
|
playingIndicator: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: 12,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
playingDot: {
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
bottomActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
actionBtn: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: theme.spacing.sm,
|
||||||
|
paddingVertical: theme.spacing.sm,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
actionBtnActive: {
|
||||||
|
backgroundColor: theme.colors.primary + '20',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
actionBtnText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
actionBtnTextActive: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
downloadAllBtn: {
|
||||||
|
backgroundColor: theme.colors.primary + '20',
|
||||||
|
},
|
||||||
|
downloadAllText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import { theme } from '../theme';
|
||||||
|
import * as Cache from '../utils/cache';
|
||||||
|
|
||||||
|
const TrashIcon = () => (
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={theme.colors.error} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InfoIcon = () => (
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
<Path d="M12 12h.01M12 16h.01" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronRight = () => (
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M9 18l6-6-6-6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SettingItemProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
rightElement?: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
showChevron?: boolean;
|
||||||
|
isDanger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingItem: React.FC<SettingItemProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
rightElement,
|
||||||
|
onPress,
|
||||||
|
showChevron = false,
|
||||||
|
isDanger = false,
|
||||||
|
}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingItem}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={!onPress}
|
||||||
|
>
|
||||||
|
<View style={styles.settingItemLeft}>
|
||||||
|
<Text style={[styles.settingTitle, isDanger && styles.settingTitleDanger]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text style={styles.settingSubtitle}>{subtitle}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{rightElement}
|
||||||
|
{showChevron && <ChevronRight />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SettingsScreenProps {
|
||||||
|
cacheOnly: boolean;
|
||||||
|
resumePlaybackEnabled: boolean;
|
||||||
|
playlistFiles: string[];
|
||||||
|
currentPlaylistFile: string;
|
||||||
|
cachedTrackCount: number;
|
||||||
|
trackCount: number;
|
||||||
|
onCacheOnlyChange: (value: boolean) => Promise<void>;
|
||||||
|
onResumePlaybackChange: (value: boolean) => Promise<void>;
|
||||||
|
onCacheChanged: () => Promise<void> | void;
|
||||||
|
onPlaylistChange: (fileName: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||||
|
cacheOnly,
|
||||||
|
resumePlaybackEnabled,
|
||||||
|
playlistFiles,
|
||||||
|
currentPlaylistFile,
|
||||||
|
cachedTrackCount,
|
||||||
|
trackCount,
|
||||||
|
onCacheOnlyChange,
|
||||||
|
onResumePlaybackChange,
|
||||||
|
onCacheChanged,
|
||||||
|
onPlaylistChange,
|
||||||
|
}) => {
|
||||||
|
const [cacheSize, setCacheSize] = useState<string>('0 B');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [playlistExpanded, setPlaylistExpanded] = useState(false);
|
||||||
|
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
|
||||||
|
|
||||||
|
const loadCacheSize = async () => {
|
||||||
|
try {
|
||||||
|
const size = await Cache.getCacheSize();
|
||||||
|
setCacheSize(Cache.formatSize(size));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get cache size:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCacheOnly = async (value: boolean) => {
|
||||||
|
try {
|
||||||
|
await onCacheOnlyChange(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save setting:', error);
|
||||||
|
Alert.alert('错误', '保存设置失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleResumePlayback = async (value: boolean) => {
|
||||||
|
try {
|
||||||
|
await onResumePlaybackChange(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save resume playback setting:', error);
|
||||||
|
Alert.alert('错误', '保存设置失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCacheSize();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlaylistPress = async (fileName: string) => {
|
||||||
|
if (fileName === currentPlaylistFile || isSwitchingPlaylist) {
|
||||||
|
setPlaylistExpanded(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSwitchingPlaylist(true);
|
||||||
|
try {
|
||||||
|
await onPlaylistChange(fileName);
|
||||||
|
setPlaylistExpanded(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换歌单失败:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSwitchingPlaylist(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearCache = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'清除缓存',
|
||||||
|
'确定要清除所有缓存的音频文件吗?这不会删除歌曲列表。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '清除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await Cache.clearAllCache();
|
||||||
|
await onCacheChanged();
|
||||||
|
await loadCacheSize();
|
||||||
|
Alert.alert('完成', '缓存已清除');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', '清除缓存失败');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>设置</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>播放设置</Text>
|
||||||
|
<SettingItem
|
||||||
|
title="当前歌单"
|
||||||
|
subtitle={currentPlaylistFile}
|
||||||
|
onPress={() => setPlaylistExpanded((prev) => !prev)}
|
||||||
|
rightElement={
|
||||||
|
isSwitchingPlaylist
|
||||||
|
? <ActivityIndicator size="small" color={theme.colors.primary} />
|
||||||
|
: <Text style={styles.settingMeta}>{cachedTrackCount}/{trackCount} 已缓存</Text>
|
||||||
|
}
|
||||||
|
showChevron={!isSwitchingPlaylist}
|
||||||
|
/>
|
||||||
|
{playlistExpanded && (
|
||||||
|
<View style={styles.playlistOptions}>
|
||||||
|
{playlistFiles.map((fileName) => {
|
||||||
|
const isActive = fileName === currentPlaylistFile;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={fileName}
|
||||||
|
style={[styles.playlistOption, isActive && styles.playlistOptionActive]}
|
||||||
|
onPress={() => { void handlePlaylistPress(fileName); }}
|
||||||
|
disabled={isSwitchingPlaylist}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.playlistOptionText, isActive && styles.playlistOptionTextActive]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</Text>
|
||||||
|
{isActive && <Text style={styles.playlistOptionBadge}>当前</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Text style={styles.playlistHint}>
|
||||||
|
自动读取所有以 playlist.json 结尾的歌单,并记住你上次选择。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<View style={styles.settingItemLeft}>
|
||||||
|
<Text style={styles.settingTitle}>记住上次位置继续听</Text>
|
||||||
|
<Text style={styles.settingSubtitle}>默认开启,下次打开时从上次暂停的位置继续播放</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={resumePlaybackEnabled}
|
||||||
|
onValueChange={toggleResumePlayback}
|
||||||
|
trackColor={{ false: theme.colors.surface2, true: theme.colors.primarySoft }}
|
||||||
|
thumbColor={resumePlaybackEnabled ? theme.colors.primary : theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<View style={styles.settingItemLeft}>
|
||||||
|
<Text style={styles.settingTitle}>只用缓存听歌曲</Text>
|
||||||
|
<Text style={styles.settingSubtitle}>节约流量,只播放已缓存的歌曲</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={cacheOnly}
|
||||||
|
onValueChange={toggleCacheOnly}
|
||||||
|
trackColor={{ false: theme.colors.surface2, true: theme.colors.primarySoft }}
|
||||||
|
thumbColor={cacheOnly ? theme.colors.primary : theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>缓存管理</Text>
|
||||||
|
<SettingItem
|
||||||
|
title="已缓存大小"
|
||||||
|
subtitle={cacheSize}
|
||||||
|
rightElement={<InfoIcon />}
|
||||||
|
/>
|
||||||
|
<SettingItem
|
||||||
|
title="清除所有缓存"
|
||||||
|
onPress={handleClearCache}
|
||||||
|
isDanger={true}
|
||||||
|
rightElement={<TrashIcon />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>关于</Text>
|
||||||
|
<View style={styles.aboutCard}>
|
||||||
|
<Text style={styles.aboutTitle}>观空禅音</Text>
|
||||||
|
<Text style={styles.aboutEnglish}>ZenVoid</Text>
|
||||||
|
<Text style={styles.aboutBody}>
|
||||||
|
这不是一款为了喧闹和效率而设计的播放器。它更像一处留白,让人愿意慢下来,听见一点安静。
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.aboutBody}>
|
||||||
|
“ZenVoid”和“观空”都不是标准答案,只是我们尽力靠近这种感受的一种表达。希望它陪你在日常里,留一点空,听一点静。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<SettingItem
|
||||||
|
title="版本"
|
||||||
|
subtitle="1.0.0"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginTop: theme.spacing.md,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
settingItemLeft: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
settingTitleDanger: {
|
||||||
|
color: theme.colors.error,
|
||||||
|
},
|
||||||
|
settingSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
settingMeta: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
marginRight: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
playlistOptions: {
|
||||||
|
marginTop: 1,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingTop: theme.spacing.sm,
|
||||||
|
paddingBottom: theme.spacing.md,
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
playlistOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
paddingHorizontal: theme.spacing.sm,
|
||||||
|
paddingVertical: theme.spacing.sm,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||||
|
},
|
||||||
|
playlistOptionActive: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
backgroundColor: theme.colors.primarySoft,
|
||||||
|
},
|
||||||
|
playlistOptionText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
playlistOptionTextActive: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
playlistOptionBadge: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
playlistHint: {
|
||||||
|
marginTop: theme.spacing.xs,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
aboutCard: {
|
||||||
|
marginHorizontal: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
aboutTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
aboutEnglish: {
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
aboutBody: {
|
||||||
|
marginTop: theme.spacing.sm,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 22,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { TextStyle, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
|
interface Typography {
|
||||||
|
[key: string]: TextStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = {
|
||||||
|
colors: {
|
||||||
|
background: '#060612',
|
||||||
|
backgroundMid: '#0a0a1e',
|
||||||
|
surface: 'rgba(255,255,255,0.06)',
|
||||||
|
surface2: 'rgba(255,255,255,0.10)',
|
||||||
|
border: 'rgba(255,255,255,0.08)',
|
||||||
|
primary: '#e8b86d',
|
||||||
|
primaryDim: '#a07840',
|
||||||
|
primaryGlow: 'rgba(232,184,109,0.15)',
|
||||||
|
primarySoft: 'rgba(232,184,109,0.08)',
|
||||||
|
text: '#e8e6f0',
|
||||||
|
textSecondary: '#7a7890',
|
||||||
|
success: '#4ade80',
|
||||||
|
warning: '#fbbf24',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 20,
|
||||||
|
xxl: 24,
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
h1: { fontSize: 28, fontWeight: 'bold' } as TextStyle,
|
||||||
|
h2: { fontSize: 24, fontWeight: 'bold' } as TextStyle,
|
||||||
|
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
|
||||||
|
body: { fontSize: 16, fontWeight: 'normal' } as TextStyle,
|
||||||
|
bodySmall: { fontSize: 14, fontWeight: 'normal' } as TextStyle,
|
||||||
|
caption: { fontSize: 12, fontWeight: 'normal' } as TextStyle,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface Track {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
src: string;
|
||||||
|
cover?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RepeatMode = 'none' | 'one' | 'all';
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
playlist: Track[];
|
||||||
|
currentIndex: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isShuffle: boolean;
|
||||||
|
repeatMode: RepeatMode;
|
||||||
|
position: number;
|
||||||
|
duration: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheInfo {
|
||||||
|
[src: string]: {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
downloadedAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { CacheInfo, Track } from '../types';
|
||||||
|
|
||||||
|
const CACHE_DIR = `${FileSystem.documentDirectory}audio_cache/`;
|
||||||
|
const CACHE_INFO_KEY = '@zen_box_cache_info';
|
||||||
|
const CACHE_ONLY_KEY = '@zen_box_cache_only';
|
||||||
|
|
||||||
|
let cacheInfo: CacheInfo = {};
|
||||||
|
|
||||||
|
export const initCache = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// 确保缓存目录存在
|
||||||
|
const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR);
|
||||||
|
if (!dirInfo.exists) {
|
||||||
|
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载缓存信息
|
||||||
|
const cachedInfo = await AsyncStorage.getItem(CACHE_INFO_KEY);
|
||||||
|
if (cachedInfo) {
|
||||||
|
cacheInfo = JSON.parse(cachedInfo);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化缓存失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCacheInfo = (): CacheInfo => {
|
||||||
|
return { ...cacheInfo };
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DownloadAllResult {
|
||||||
|
successCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
failedTracks: string[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateCacheKey = (url: string): string => {
|
||||||
|
// 简单的 URL 哈希
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < url.length; i++) {
|
||||||
|
const char = url.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36) + '.mp3';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCached = (url: string): boolean => {
|
||||||
|
return !!cacheInfo[url];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCachedPath = (url: string): string | null => {
|
||||||
|
if (cacheInfo[url]) {
|
||||||
|
return cacheInfo[url].path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// 安全地编码 URL 中的路径部分,保留协议、域名等
|
||||||
|
const encodeUrlPath = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
// 只编码路径部分,不要编码整个 URL
|
||||||
|
const pathParts = parsedUrl.pathname.split('/');
|
||||||
|
const encodedPathParts = pathParts.map((part, index) => {
|
||||||
|
// 不要编码空字符串(开头的斜杠)
|
||||||
|
if (part === '') return part;
|
||||||
|
// 使用 encodeURIComponent 编码每个路径段
|
||||||
|
return encodeURIComponent(decodeURIComponent(part));
|
||||||
|
});
|
||||||
|
parsedUrl.pathname = encodedPathParts.join('/');
|
||||||
|
const finalUrl = parsedUrl.toString();
|
||||||
|
console.log(`[URL] 原始: ${url}`);
|
||||||
|
console.log(`[URL] 编码后: ${finalUrl}`);
|
||||||
|
return finalUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[URL] 编码失败:', error);
|
||||||
|
// 如果解析失败,尝试简单编码
|
||||||
|
try {
|
||||||
|
const encoded = encodeURI(url);
|
||||||
|
console.log(`[URL] 简单编码: ${encoded}`);
|
||||||
|
return encoded;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadAndCache = async (
|
||||||
|
url: string,
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
): Promise<string> => {
|
||||||
|
const maxRetries = 3;
|
||||||
|
let retryCount = 0;
|
||||||
|
|
||||||
|
// 使用原始 URL 作为缓存键,但下载时使用编码后的 URL
|
||||||
|
const cacheKey = generateCacheKey(url);
|
||||||
|
const cachePath = `${CACHE_DIR}${cacheKey}`;
|
||||||
|
|
||||||
|
// 对 URL 进行编码,特别处理中文路径
|
||||||
|
const encodedUrl = encodeUrlPath(url);
|
||||||
|
|
||||||
|
while (retryCount < maxRetries) {
|
||||||
|
try {
|
||||||
|
console.log(`[下载] 开始下载: ${encodedUrl}`);
|
||||||
|
|
||||||
|
// 添加更多下载选项
|
||||||
|
const downloadResumable = FileSystem.createDownloadResumable(
|
||||||
|
encodedUrl,
|
||||||
|
cachePath,
|
||||||
|
{
|
||||||
|
// 设置一些常用的请求头,兼容 OSS
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'ZenBox/1.0',
|
||||||
|
'Accept': 'audio/mpeg,audio/*;q=0.9,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(downloadProgress) => {
|
||||||
|
if (onProgress && downloadProgress.totalBytesWritten > 0 && downloadProgress.totalBytesExpectedToWrite > 0) {
|
||||||
|
const progress = downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite;
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadResult = await downloadResumable.downloadAsync();
|
||||||
|
|
||||||
|
if (downloadResult && downloadResult.status === 200) {
|
||||||
|
console.log(`[下载] 成功: ${encodedUrl}`);
|
||||||
|
|
||||||
|
const fileInfo = await FileSystem.getInfoAsync(cachePath);
|
||||||
|
const size = fileInfo.exists ? (fileInfo as any).size || 0 : 0;
|
||||||
|
|
||||||
|
cacheInfo[url] = {
|
||||||
|
path: cachePath,
|
||||||
|
size,
|
||||||
|
downloadedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[下载] 失败,状态码: ${downloadResult?.status}`);
|
||||||
|
throw new Error(`下载失败,状态码: ${downloadResult?.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
retryCount++;
|
||||||
|
console.error(`[下载] 尝试 ${retryCount}/${maxRetries} 失败:`, error);
|
||||||
|
|
||||||
|
if (retryCount >= maxRetries) {
|
||||||
|
console.error(`[下载] 达到最大重试次数,放弃: ${encodedUrl}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指数退避重试
|
||||||
|
const backoffTime = Math.min(1000 * Math.pow(2, retryCount - 1), 5000);
|
||||||
|
console.log(`[下载] ${backoffTime}ms 后重试...`);
|
||||||
|
await sleep(backoffTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('下载失败,已达到最大重试次数');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCache = async (url: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (cacheInfo[url]) {
|
||||||
|
const path = cacheInfo[url].path;
|
||||||
|
await FileSystem.deleteAsync(path, { idempotent: true });
|
||||||
|
delete cacheInfo[url];
|
||||||
|
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除缓存失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllCache = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// 删除所有缓存文件
|
||||||
|
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
|
||||||
|
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
||||||
|
|
||||||
|
// 清空缓存信息
|
||||||
|
cacheInfo = {};
|
||||||
|
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清除缓存失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCacheSize = async (): Promise<number> => {
|
||||||
|
try {
|
||||||
|
let totalSize = 0;
|
||||||
|
for (const url in cacheInfo) {
|
||||||
|
totalSize += cacheInfo[url].size;
|
||||||
|
}
|
||||||
|
return totalSize;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取缓存大小失败:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadAllTracks = async (
|
||||||
|
tracks: Track[],
|
||||||
|
onProgress?: (completed: number, total: number, trackTitle: string) => void
|
||||||
|
): Promise<DownloadAllResult> => {
|
||||||
|
let successCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
const failedTracks: string[] = [];
|
||||||
|
let completedCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
const track = tracks[i];
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(completedCount, tracks.length, track.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCached(track.src)) {
|
||||||
|
try {
|
||||||
|
await downloadAndCache(track.src);
|
||||||
|
successCount += 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to download ${track.title}:`, error);
|
||||||
|
failedTracks.push(track.title);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skippedCount += 1;
|
||||||
|
}
|
||||||
|
completedCount += 1;
|
||||||
|
}
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(tracks.length, tracks.length, '完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
successCount,
|
||||||
|
skippedCount,
|
||||||
|
failedTracks,
|
||||||
|
total: tracks.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCacheOnlySetting = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const value = await AsyncStorage.getItem(CACHE_ONLY_KEY);
|
||||||
|
// 默认返回 true
|
||||||
|
return value !== 'false';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get cache only setting:', error);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setCacheOnlySetting = async (value: boolean): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(CACHE_ONLY_KEY, value.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save cache only setting:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "yangs-toolbox",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/server/index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22 <24"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "\"D:\\ProgramData\\nodejs_22\\node.exe\" src/server/index.js",
|
||||||
|
"start22": "\"D:\\ProgramData\\nodejs_22\\node.exe\" src/server/index.js",
|
||||||
|
"start:5051": "set PORT=5051&& \"D:\\ProgramData\\nodejs_22\\node.exe\" src/server/index.js",
|
||||||
|
"start:8976": "set PORT=8976&& \"D:\\ProgramData\\nodejs_22\\node.exe\" src/server/index.js",
|
||||||
|
"start:8977": "set PORT=8977&& \"D:\\ProgramData\\nodejs_22\\node.exe\" src/server/index.js",
|
||||||
|
"upgrade:expense": "\"D:\\ProgramData\\nodejs_22\\node.exe\" scripts/upgrade_expense_db.js",
|
||||||
|
"test": "\"D:\\ProgramData\\nodejs_22\\node.exe\" src/server/test_weekly_modular.js",
|
||||||
|
"lint": "powershell -NoProfile -Command \"Get-ChildItem -Recurse -Filter *.js -Path src,public | Where-Object { $_.FullName -notmatch '\\\\node_modules\\\\' -and $_.FullName -notmatch '\\\\public\\\\lib\\\\' -and $_.FullName -notmatch '\\\\public\\\\tools\\\\cloud_notes\\\\tinymce\\\\' -and $_.FullName -notmatch '\\\\public\\\\tools\\\\rebalance\\\\lib\\\\' -and $_.Name -notmatch '\\.min\\.js$' } | ForEach-Object { & 'D:\\\\ProgramData\\\\nodejs_22\\\\node.exe' --check $_.FullName }\"",
|
||||||
|
"typecheck": "npm run lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/pop-core": "^1.8.0",
|
||||||
|
"@univerjs/presets": "^0.19.0",
|
||||||
|
"@univerjs/protocol": "^0.1.48",
|
||||||
|
"@univerjs/ui": "^0.19.0",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"axios": "^1.7.5",
|
||||||
|
"better-sqlite3": "^9.4.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"imapflow": "^1.3.3",
|
||||||
|
"mailparser": "^3.9.8",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"nodemailer": "^6.9.8",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
|
"puppeteer": "^24.40.0",
|
||||||
|
"puppeteer-extra": "^3.3.6",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"unzipper": "^0.10.14",
|
||||||
|
"word-extractor": "^1.0.4",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"css-loader": "^7.1.4",
|
||||||
|
"esbuild": "^0.27.4",
|
||||||
|
"style-loader": "^4.0.0",
|
||||||
|
"tinymce": "^6.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
(() => {
|
||||||
|
const originalFetch = window.fetch
|
||||||
|
window.fetch = (input, init = {}) => {
|
||||||
|
const opts = Object.assign({}, init)
|
||||||
|
if (!opts.credentials) opts.credentials = 'include'
|
||||||
|
return originalFetch(input, opts)
|
||||||
|
}
|
||||||
|
const overlayId = 'authOverlay'
|
||||||
|
const ensureOverlay = () => {
|
||||||
|
let ov = document.getElementById(overlayId)
|
||||||
|
if (ov) return ov
|
||||||
|
ov = document.createElement('div')
|
||||||
|
ov.id = overlayId
|
||||||
|
ov.style.position = 'fixed'
|
||||||
|
ov.style.left = '0'; ov.style.top = '0'; ov.style.right = '0'; ov.style.bottom = '0'
|
||||||
|
ov.style.display = 'none'
|
||||||
|
ov.style.alignItems = 'center'
|
||||||
|
ov.style.justifyContent = 'center'
|
||||||
|
ov.style.background = 'rgba(0,0,0,0.6)'
|
||||||
|
ov.style.zIndex = '9999'
|
||||||
|
const panel = document.createElement('div')
|
||||||
|
panel.style.background = '#fff'
|
||||||
|
panel.style.padding = '20px'
|
||||||
|
panel.style.borderRadius = '8px'
|
||||||
|
panel.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)'
|
||||||
|
const title = document.createElement('div')
|
||||||
|
title.textContent = '鉴权失败,请重新校验'
|
||||||
|
title.style.fontSize = '16px'
|
||||||
|
title.style.marginBottom = '12px'
|
||||||
|
title.style.color = '#333'
|
||||||
|
const debug = document.createElement('pre')
|
||||||
|
debug.id = 'authDebug'
|
||||||
|
debug.style.fontSize = '12px'
|
||||||
|
debug.style.lineHeight = '1.5'
|
||||||
|
debug.style.background = '#f7f7f8'
|
||||||
|
debug.style.border = '1px solid #e3e3e7'
|
||||||
|
debug.style.padding = '10px'
|
||||||
|
debug.style.margin = '0 0 12px 0'
|
||||||
|
debug.style.maxWidth = '520px'
|
||||||
|
debug.style.maxHeight = '240px'
|
||||||
|
debug.style.overflow = 'auto'
|
||||||
|
debug.textContent = '正在收集诊断信息...'
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.textContent = '重新校验'
|
||||||
|
btn.style.padding = '8px 16px'
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await handshake()
|
||||||
|
})
|
||||||
|
panel.appendChild(title)
|
||||||
|
panel.appendChild(debug)
|
||||||
|
panel.appendChild(btn)
|
||||||
|
ov.appendChild(panel)
|
||||||
|
document.body.appendChild(ov)
|
||||||
|
return ov
|
||||||
|
}
|
||||||
|
const showOverlay = () => { ensureOverlay().style.display = 'flex' }
|
||||||
|
const hideOverlay = () => { const ov = ensureOverlay(); ov.style.display = 'none' }
|
||||||
|
let cfg = { enableCookieAuth: true, cookieName: 'rebalance_jwt' }
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/assets/auth_config.json', { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
cfg = Object.assign({}, cfg, data || {})
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
const debugState = {
|
||||||
|
url: '',
|
||||||
|
config: {},
|
||||||
|
tokenInUrl: false,
|
||||||
|
tokenCall: { tried: false, ok: false, status: 0 },
|
||||||
|
handshake: { tried: false, ok: false, status: 0 },
|
||||||
|
server: {}
|
||||||
|
}
|
||||||
|
const removeTokenFromUrl = () => {
|
||||||
|
try {
|
||||||
|
const nu = new URL(window.location.href)
|
||||||
|
nu.searchParams.delete('token')
|
||||||
|
window.history.replaceState(null, document.title, nu.toString())
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
const handleTokenInUrl = async () => {
|
||||||
|
try {
|
||||||
|
const u = new URL(window.location.href)
|
||||||
|
const token = u.searchParams.get('token')
|
||||||
|
debugState.url = u.toString()
|
||||||
|
debugState.config = { enableCookieAuth: !!cfg.enableCookieAuth, cookieName: String(cfg.cookieName || '') }
|
||||||
|
debugState.tokenInUrl = !!token
|
||||||
|
if (token) {
|
||||||
|
if (cfg.enableCookieAuth) {
|
||||||
|
const r = await fetch(`/auth/token?token=${encodeURIComponent(token)}`, { method: 'GET' })
|
||||||
|
debugState.tokenCall.tried = true
|
||||||
|
debugState.tokenCall.ok = r.ok
|
||||||
|
debugState.tokenCall.status = r.status || 0
|
||||||
|
removeTokenFromUrl()
|
||||||
|
return r.ok
|
||||||
|
} else {
|
||||||
|
removeTokenFromUrl()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const handshake = async () => {
|
||||||
|
if (!cfg.enableCookieAuth) { hideOverlay(); return true }
|
||||||
|
try {
|
||||||
|
const r = await fetch('/auth/handshake', { cache: 'no-store' })
|
||||||
|
debugState.handshake.tried = true
|
||||||
|
debugState.handshake.ok = r.ok
|
||||||
|
debugState.handshake.status = r.status || 0
|
||||||
|
/*
|
||||||
|
try {
|
||||||
|
const dbgRes = await fetch('/auth/debug', { cache: 'no-store' })
|
||||||
|
try {
|
||||||
|
const body = await dbgRes.json()
|
||||||
|
debugState.server = body
|
||||||
|
} catch (_) {
|
||||||
|
debugState.server = { error: 'debug parse failed', status: dbgRes.status || 0 }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugState.server = { error: 'debug request failed' }
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
const box = document.getElementById('authDebug')
|
||||||
|
if (box) {
|
||||||
|
const lines = []
|
||||||
|
lines.push(`URL: ${debugState.url}`)
|
||||||
|
lines.push(`Config: enableCookieAuth=${debugState.config.enableCookieAuth} cookieName=${debugState.config.cookieName}`)
|
||||||
|
lines.push(`TokenInUrl: ${debugState.tokenInUrl}`)
|
||||||
|
lines.push(`TokenCall: tried=${debugState.tokenCall.tried} ok=${debugState.tokenCall.ok} status=${debugState.tokenCall.status}`)
|
||||||
|
lines.push(`Handshake: tried=${debugState.handshake.tried} ok=${debugState.handshake.ok} status=${debugState.handshake.status}`)
|
||||||
|
if (debugState.server && typeof debugState.server === 'object') {
|
||||||
|
lines.push(`Server.enableAuth: ${debugState.server.enableAuth}`)
|
||||||
|
lines.push(`Server.jwkLoaded: ${debugState.server.jwkLoaded}`)
|
||||||
|
lines.push(`Server.cookieName: ${debugState.server.cookieName}`)
|
||||||
|
lines.push(`Server.cookiePresent: ${debugState.server.cookiePresent}`)
|
||||||
|
lines.push(`Server.verified: ${debugState.server.verified}`)
|
||||||
|
lines.push(`Server.keysCount: ${debugState.server.keysCount}`)
|
||||||
|
try {
|
||||||
|
const ks = Array.isArray(debugState.server.keysSources) ? debugState.server.keysSources : []
|
||||||
|
lines.push(`Server.keysSources: ${JSON.stringify(ks)}`)
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
const th = debugState.server.tokenHeader || {}
|
||||||
|
const tp = debugState.server.tokenPayload || {}
|
||||||
|
lines.push(`Server.tokenHeader.alg: ${th.alg || ''}`)
|
||||||
|
lines.push(`Server.tokenPayload.iss: ${tp.iss || ''}`)
|
||||||
|
lines.push(`Server.tokenPayload.aud: ${tp.aud || ''}`)
|
||||||
|
lines.push(`Server.tokenPayload.exp: ${tp.exp || ''}`)
|
||||||
|
} catch (_) {}
|
||||||
|
if (debugState.server.error) {
|
||||||
|
lines.push(`Server.error: ${debugState.server.error}`)
|
||||||
|
}
|
||||||
|
const rq = debugState.server.request || {}
|
||||||
|
lines.push(`Server.request.host: ${rq.host || ''}`)
|
||||||
|
lines.push(`Server.request.path: ${rq.path || ''}`)
|
||||||
|
lines.push(`Server.request.origin: ${rq.origin || ''}`)
|
||||||
|
lines.push(`Server.request.secFetchMode: ${rq.secFetchMode || ''}`)
|
||||||
|
lines.push(`Server.request.forwardedProto: ${rq.forwardedProto || ''}`)
|
||||||
|
lines.push(`Server.request.secure: ${rq.secure}`)
|
||||||
|
lines.push(`Server.request.proto: ${rq.proto || ''}`)
|
||||||
|
} else {
|
||||||
|
lines.push(`Server: ${String(debugState.server)}`)
|
||||||
|
}
|
||||||
|
lines.push(`Expected URLs: /auth/token?token=... , /auth/handshake , /api/...`)
|
||||||
|
box.textContent = lines.join('\n')
|
||||||
|
}
|
||||||
|
if (r.ok) { hideOverlay(); return true }
|
||||||
|
showOverlay(); return false
|
||||||
|
} catch (_) { showOverlay(); return false }
|
||||||
|
}
|
||||||
|
const init = async () => {
|
||||||
|
await loadConfig()
|
||||||
|
await handleTokenInUrl()
|
||||||
|
await handshake()
|
||||||
|
}
|
||||||
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
|
init()
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', init)
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"_comment": "公共静态资源下,与 Rebalance 相关的 JWT 鉴权配置",
|
||||||
|
"cookieName_comment": "用于识别已登录用户的 JWT cookie 名称",
|
||||||
|
"cookieName": "rebalance_jwt",
|
||||||
|
"enableCookieAuth_comment": "true:启用基于 cookie 的鉴权;false:不校验登录状态",
|
||||||
|
"enableCookieAuth": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
* { box-sizing: border-box }
|
||||||
|
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif }
|
||||||
|
.topbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid #ddd; position: sticky; top: 0; background: #fff; z-index: 10 }
|
||||||
|
.tabs { display: flex; gap: 8px }
|
||||||
|
.tab { border: none; background: #f5f5f5; padding: 8px 12px; border-radius: 8px; cursor: pointer; text-decoration: none; color: inherit }
|
||||||
|
.tab.active { background: #222; color: #fff }
|
||||||
|
.settings { border: none; background: #fff; padding: 6px 10px; border: 1px solid #222; border-radius: 6px; cursor: pointer }
|
||||||
|
main { padding: 12px }
|
||||||
|
.modal { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.35); z-index: 1000 }
|
||||||
|
.hidden { display: none }
|
||||||
|
.modal-body { background: #fff; width: 520px; max-width: 90vw; border-radius: 10px; padding: 16px; display: grid; gap: 8px }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 8px }
|
||||||
|
mark { background: #ffe58f }
|
||||||
|
.kw-0 { background: #ffe58f }
|
||||||
|
.kw-1 { background: #ffd6e7 }
|
||||||
|
.kw-2 { background: #d6f5ff }
|
||||||
|
.kw-3 { background: #d9f7be }
|
||||||
|
.kw-4 { background: #fff1b8 }
|
||||||
|
.kw-5 { background: #efdbff }
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const el = id => document.getElementById(id)
|
||||||
|
const highlight = (text, terms) => {
|
||||||
|
if (!terms || terms.length === 0) return text
|
||||||
|
let s = text
|
||||||
|
terms.forEach((t, i) => {
|
||||||
|
const cls = `kw-${i % 6}`
|
||||||
|
const re = new RegExp(`(${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
||||||
|
s = s.replace(re, `<mark class="${cls}">$1</mark>`)
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtCNDateTime = (d) => {
|
||||||
|
const date = d instanceof Date ? d : new Date()
|
||||||
|
try {
|
||||||
|
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
|
||||||
|
return s.replace(/\//g, '-').replace(/\u200E/g, '')
|
||||||
|
} catch {
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
const y = date.getFullYear(), m = pad(date.getMonth()+1), day = pad(date.getDate()), hh = pad(date.getHours()), mm = pad(date.getMinutes()), ss = pad(date.getSeconds())
|
||||||
|
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtCNSimpleTime = (d) => {
|
||||||
|
const date = d instanceof Date ? d : new Date()
|
||||||
|
try {
|
||||||
|
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
|
||||||
|
return s.replace(/\u200E/g, '')
|
||||||
|
} catch {
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cnDateStr = (d) => {
|
||||||
|
const date = d instanceof Date ? d : new Date()
|
||||||
|
try {
|
||||||
|
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date)
|
||||||
|
return s.replace(/\//g, '-').replace(/\u200E/g, '')
|
||||||
|
} catch {
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fmtCNDateTime = fmtCNDateTime
|
||||||
|
window.fmtCNSimpleTime = fmtCNSimpleTime
|
||||||
|
window.cnDateStr = cnDateStr
|
||||||
|
|
||||||
|
;(function handleSetToken() {
|
||||||
|
try {
|
||||||
|
const u = new URL(window.location.href)
|
||||||
|
const token = u.searchParams.get('set_token')
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('toolbox_creds_token', token)
|
||||||
|
u.searchParams.delete('set_token')
|
||||||
|
window.history.replaceState(null, '', u.toString())
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<header class="topbar">
|
||||||
|
<nav class="tabs">
|
||||||
|
<a class="tab" href="/go?systemId=Tools-weibo" data-key="weibo">微博跟踪</a>
|
||||||
|
<a class="tab" href="/go?systemId=Tools-markets" data-key="markets">市场跟踪</a>
|
||||||
|
<a class="tab" href="/go?systemId=Tools-wall-" data-key="wall">告知墙</a>
|
||||||
|
<a class="tab" href="/go?systemId=Tools-funds" data-key="funds">本金变动池</a>
|
||||||
|
<a class="tab" href="/go?systemId=Tools-ccb_private_funds" data-key="ccb_private_funds">建行私募理财</a>
|
||||||
|
<a class="tab" href="/go?systemId=Tools-cloud" data-key="cloud">云产品管理</a>
|
||||||
|
<a class="tab" href="/go?systemId=Tools-weekly" data-key="weekly">工作周报</a>
|
||||||
|
<a class="tab" href="/go?systemId=TRAE-Rebalance&url=https%3A%2F%2Ftraesite.umersoft.com%3A8960%2FRebalance.html" data-key="rebalance">再平衡</a>
|
||||||
|
<a class="tab" href="/go?systemId=Wager&url=https%3A%2F%2Ftraesite.umersoft.com%3A8956" data-key="wager">投注</a>
|
||||||
|
<a class="tab" href="/go?systemId=TRAE-PSC&url=https%3A%2F%2Ftraesite.umersoft.com%3A8969%2F" data-key="disk">安全网盘</a>
|
||||||
|
<a class="tab" href="/go?systemId=FitnessDiary" data-key="fitness">运动日志</a>
|
||||||
|
<a class="tab" href="/go?systemId=CalendarReminder&url=https%3A%2F%2Ftraesite.umersoft.com%3A8972%2F" data-key="fitness">日历提醒</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
(async function(){
|
||||||
|
try {
|
||||||
|
const wrap = document.querySelector('#topbar')
|
||||||
|
if (!wrap) return
|
||||||
|
const res = await fetch('/common/nav.html')
|
||||||
|
const html = await res.text()
|
||||||
|
wrap.innerHTML = html
|
||||||
|
const path = location.pathname.toLowerCase()
|
||||||
|
let key = ''
|
||||||
|
if (path.includes('/tools/weibo/')) key = 'weibo'
|
||||||
|
else if (path.includes('/tools/markets/')) key = 'markets'
|
||||||
|
else if (path.includes('/tools/wall/')) key = 'wall'
|
||||||
|
else if (path.includes('/tools/funds/')) key = 'funds'
|
||||||
|
else if (path.includes('/tools/ccb_private_funds/')) key = 'ccb_private_funds'
|
||||||
|
else if (path.includes('/tools/cloud/')) key = 'cloud'
|
||||||
|
else if (path.includes('/tools/weekly/')) key = 'weekly'
|
||||||
|
else if (path.includes('/tools/ai-lib/')) key = 'ai-lib'
|
||||||
|
else if (path.includes('/tools/expense/')) key = 'expense'
|
||||||
|
if (key) {
|
||||||
|
wrap.querySelectorAll('a.tab').forEach(a => {
|
||||||
|
if (a.dataset.key === key) a.classList.add('active')
|
||||||
|
else a.classList.remove('active')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
[
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "地下偶像相关揭示板",
|
||||||
|
"url": "https://m.weibo.cn/u/7716940453",
|
||||||
|
"icon": "text_italic.png"
|
||||||
|
},{
|
||||||
|
"name": "世界树剧场",
|
||||||
|
"url": "https://m.weibo.cn/u/7925328421",
|
||||||
|
"icon": "user_go.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "育音堂",
|
||||||
|
"url": "https://weibo.com/u/1802208671",
|
||||||
|
"icon": "weather_sun.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "X下载利器",
|
||||||
|
"url": "https://ssstwitter.com/",
|
||||||
|
"icon": "twitter_tool.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "上海地下偶像联盟",
|
||||||
|
"url": "https://m.weibo.cn/u/7912812433",
|
||||||
|
"icon": "weibo.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "免费ICON",
|
||||||
|
"url": "https://igoutu.cn/icons",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "培训/导航/LOGO",
|
||||||
|
"url": "http://112.74.31.221:8888/default.html",
|
||||||
|
"icon": "HDA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Git 服务器",
|
||||||
|
"url": "http://47.101.56.101:8998/Home/LogOn?ReturnUrl=%2F",
|
||||||
|
"icon": "HDA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "禅道 项目系统",
|
||||||
|
"url": "http://112.74.31.221:82/zentao/user-login-L3plbnRhby9teS5odG1s.html",
|
||||||
|
"icon": "HDA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "阿里云-合规报告",
|
||||||
|
"url": "https://security.aliyun.com/trust-center/compliance?spm=5176.32436255.J_JIpOXHuIiVOM6_FDQtQLN.2.377618c54gQlJa",
|
||||||
|
"icon": "aliyun.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "硅谷-阿里合同",
|
||||||
|
"url": "https://traesite.umersoft.com:8975/download_files/%E9%98%BF%E9%87%8C%E4%BA%91%E6%9C%8D%E5%8A%A1%E5%8D%8F%E8%AE%AE.pdf",
|
||||||
|
"icon": "aliyun.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "在线MP3播放",
|
||||||
|
"url": "https://music.cpp-prog.com",
|
||||||
|
"icon": "music.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "上海市医疗器械追溯报告系统",
|
||||||
|
"url": "https://zssb.yjj.sh.gov.cn",
|
||||||
|
"icon": "GOV.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "虚拟显示器,USB-MMIDD 驱动",
|
||||||
|
"url": "https://traesite.umersoft.com:8975/download_files/usbmmidd_v2.zip",
|
||||||
|
"icon": "download.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CHROME纯净版X64",
|
||||||
|
"url": "https://traesite.umersoft.com:8975/download_files/ChromeStandaloneSetup64.exe",
|
||||||
|
"icon": "download.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "测试长标题如果不指定图标",
|
||||||
|
"url": "#",
|
||||||
|
"icon": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
:root {
|
||||||
|
--vscode-bg: #1e1e1e;
|
||||||
|
--vscode-sidebar-bg: #252526;
|
||||||
|
--vscode-toolbar-bg: #2d2d30;
|
||||||
|
--vscode-border: #3e3e42;
|
||||||
|
--vscode-hover: #2a2d2e;
|
||||||
|
--vscode-active: #007acc;
|
||||||
|
--vscode-text: #cccccc;
|
||||||
|
--vscode-text-muted: #858585;
|
||||||
|
--vscode-accent: #4ec9b0;
|
||||||
|
--vscode-warning: #ffcc00;
|
||||||
|
--vscode-error: #f44747;
|
||||||
|
--vscode-success: #4ec9b0;
|
||||||
|
--vscode-radius: 4px;
|
||||||
|
--vscode-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
--vscode-input-bg: #3c3c3c;
|
||||||
|
--vscode-button-bg: #0e639c;
|
||||||
|
--vscode-button-hover: #1177bb;
|
||||||
|
--vscode-card-bg: #2d2d30;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.theme-cloud {
|
||||||
|
background: var(--vscode-bg);
|
||||||
|
color: var(--vscode-text);
|
||||||
|
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--vscode-sidebar-bg);
|
||||||
|
border: 1px solid var(--vscode-border);
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--vscode-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
color: var(--vscode-text);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--vscode-toolbar-bg);
|
||||||
|
border: 1px solid var(--vscode-border);
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: var(--vscode-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar span {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--vscode-card-bg);
|
||||||
|
border: 1px solid var(--vscode-border);
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--vscode-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree View Styles */
|
||||||
|
.tree-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item-header:hover {
|
||||||
|
background: var(--vscode-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item-header.expanded {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-text-muted);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-icon, .module-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item-header:hover .item-actions,
|
||||||
|
.tree-child-item:hover .item-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item-content {
|
||||||
|
margin-left: 22px;
|
||||||
|
border-left: 1px solid rgba(255,255,255,0.05);
|
||||||
|
display: none;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item-content.expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-module-wrapper {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-child-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-child-item:hover {
|
||||||
|
background: var(--vscode-hover);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input, .form textarea, .form select {
|
||||||
|
background: var(--vscode-input-bg);
|
||||||
|
border: 1px solid var(--vscode-border);
|
||||||
|
color: var(--vscode-text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input:focus, .form textarea:focus, .form select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vscode-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: var(--vscode-button-bg);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--vscode-button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--vscode-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: var(--vscode-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.sm {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover { color: var(--vscode-error); }
|
||||||
|
.btn-edit:hover { color: var(--vscode-accent); }
|
||||||
|
|
||||||
|
/* Note List Styles */
|
||||||
|
.note-item {
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-left: 3px solid var(--vscode-accent);
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 0 var(--vscode-radius) var(--vscode-radius) 0;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:hover {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--vscode-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-preview {
|
||||||
|
color: #ccc;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachment Styles */
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
color: var(--vscode-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Group Styles */
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--vscode-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter-major-dynamic {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#filter-major-dynamic label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.radio-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--vscode-input-bg);
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
border: 1px solid var(--vscode-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.radio-group .label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.radio-group label {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--vscode-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sys-major-dynamic {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#sys-major-dynamic label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="radio"] {
|
||||||
|
accent-color: var(--vscode-active);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--vscode-card-bg);
|
||||||
|
border: 1px solid var(--vscode-border);
|
||||||
|
border-radius: var(--vscode-radius);
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90%;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#category-manager-modal .modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--vscode-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-text-muted);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--vscode-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--vscode-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--vscode-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--vscode-border);
|
||||||
|
color: var(--vscode-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Form Styles */
|
||||||
|
#auth {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 100px auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Overlay Styles */
|
||||||
|
.loading-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 2000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 4px solid #fff;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>AI开发库</title>
|
||||||
|
<link rel="icon" href="/icons/ai-lib.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="/common/common.css" />
|
||||||
|
<link rel="stylesheet" href="/tools/cloud/theme/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/tools/cloud/theme/components.css" />
|
||||||
|
<link rel="stylesheet" href="/tools/ai-lib/ai-lib.css" />
|
||||||
|
</head>
|
||||||
|
<body class="theme-cloud">
|
||||||
|
<main class="container">
|
||||||
|
<section id="auth" class="card">
|
||||||
|
<h2>🔐 登录AI开发库</h2>
|
||||||
|
<div class="form">
|
||||||
|
<input id="login-user" placeholder="👤 用户名" autocomplete="off" readonly />
|
||||||
|
<input id="login-pass" placeholder="🔒 密码" type="password" autocomplete="off" readonly />
|
||||||
|
<button id="btn-login" class="btn">🚀 登录</button>
|
||||||
|
</div>
|
||||||
|
<div id="auth-status" class="status" style="display:none"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="app" style="display:none">
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>📦 系统列表</h3>
|
||||||
|
<button id="btn-refresh-tree" class="btn-icon" data-tip="刷新系统树形">🔄</button>
|
||||||
|
<button id="btn-manage-categories" class="btn-icon" data-tip="大类管理">🔧</button>
|
||||||
|
<button id="btn-new-system" class="btn">➕ 新增系统</button>
|
||||||
|
</div>
|
||||||
|
<div id="systems" class="list"></div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="toolbar">
|
||||||
|
<span id="current-path">请选择系统 → 子模块 → 开发纪要</span>
|
||||||
|
<div id="filter-major-category" class="filter-group">
|
||||||
|
<label><input type="radio" name="filter-major" value="" checked> 全部</label>
|
||||||
|
<span id="filter-major-dynamic"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="system-detail" class="card" style="display:none">
|
||||||
|
<h3>📦 系统详情</h3>
|
||||||
|
<div class="form">
|
||||||
|
<div class="form-row radio-group">
|
||||||
|
<span class="label">大类:</span>
|
||||||
|
<span id="sys-major-dynamic"></span>
|
||||||
|
</div>
|
||||||
|
<input id="sys-name" placeholder="🏷️ 系统名称" data-tip="系统名称,建议简洁唯一" />
|
||||||
|
<textarea id="sys-intro" rows="3" placeholder="📝 系统简介" data-tip="系统简介:用途、边界与关键技术"></textarea>
|
||||||
|
<button id="btn-save-system" class="btn">💾 保存系统</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="module-detail" class="card" style="display:none">
|
||||||
|
<h3>🔧 子模块详情</h3>
|
||||||
|
<div class="form">
|
||||||
|
<input id="mod-name" placeholder="🏷️ 子模块名称" data-tip="子模块名称" />
|
||||||
|
<input id="mod-desc" placeholder="📝 子模块说明" data-tip="子模块说明:功能与职责" />
|
||||||
|
<input id="mod-addr" placeholder="🌐 服务器地址" data-tip="服务器地址或域名" />
|
||||||
|
<input id="mod-port" placeholder="🔌 服务器端口" data-tip="端口号" />
|
||||||
|
<input id="mod-code" placeholder="💻 代码类型" data-tip="代码类型或技术栈" />
|
||||||
|
<input id="mod-deploy" placeholder="🚀 部署方式" data-tip="部署方式,如 Docker/K8s/物理机" />
|
||||||
|
</div>
|
||||||
|
<div class="form">
|
||||||
|
<textarea id="mod-soft" rows="8" placeholder="📦 涉及部署软件和版本(多行)" data-tip="涉及部署软件及版本(每行一条)"></textarea>
|
||||||
|
<button id="btn-save-module" class="btn">💾 保存子模块</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="note-detail" class="card" style="display:none">
|
||||||
|
<h3>📝 开发纪要详情</h3>
|
||||||
|
<div class="form">
|
||||||
|
<select id="note-type" data-tip="纪要类型(需求/开发/部署/维护)">
|
||||||
|
<option value="需求">📋 需求</option>
|
||||||
|
<option value="开发">💻 开发</option>
|
||||||
|
<option value="部署">🚀 部署</option>
|
||||||
|
<option value="维护">🔧 维护</option>
|
||||||
|
</select>
|
||||||
|
<textarea id="note-content" rows="12" placeholder="📝 纪要内容(多行)" data-tip="纪要内容,支持多行文本"></textarea>
|
||||||
|
<button id="btn-save-note" class="btn">💾 保存纪要</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="attachment-section" style="margin-top: 24px;">
|
||||||
|
<h4>📎 附件管理</h4>
|
||||||
|
<div class="form">
|
||||||
|
<input type="file" id="attachment-file" multiple data-tip="选择要上传的附件,支持多选" />
|
||||||
|
<button id="btn-upload-attachment" class="btn">📤 上传选中文件</button>
|
||||||
|
</div>
|
||||||
|
<div id="upload-progress" style="margin-top: 16px;"></div>
|
||||||
|
|
||||||
|
<div class="attachment-list" style="margin-top: 20px;">
|
||||||
|
<h5>📋 附件列表</h5>
|
||||||
|
<div id="attachments" class="list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="modal-overlay">
|
||||||
|
<div class="modal" id="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-title"></h3>
|
||||||
|
<button class="modal-close" id="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modal-body"></div>
|
||||||
|
<div class="modal-footer" id="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="modal-cancel">取消</button>
|
||||||
|
<button class="btn" id="modal-confirm">确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="loading-overlay" class="loading-overlay" style="display:none">
|
||||||
|
<div class="loading-box">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div id="loading-text">正在处理,请稍候...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/common/common.js"></script>
|
||||||
|
<script src="/tools/ai-lib/ai-lib.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,980 @@
|
|||||||
|
const el = (id) => document.getElementById(id)
|
||||||
|
|
||||||
|
const fmtNum = (v, d = 1) => {
|
||||||
|
if (v === null || v === undefined || Number.isNaN(v)) return '-'
|
||||||
|
return Number(v).toFixed(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDate = (s) => {
|
||||||
|
if (!s) return '-'
|
||||||
|
return String(s).slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadScript = (src) => new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script')
|
||||||
|
s.src = src
|
||||||
|
s.async = true
|
||||||
|
s.onload = () => resolve()
|
||||||
|
s.onerror = () => reject()
|
||||||
|
document.head.appendChild(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureChart = async () => !!window.Chart
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
charts: {},
|
||||||
|
lastData: null,
|
||||||
|
activeTab: 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const setText = (id, value) => {
|
||||||
|
const node = el(id)
|
||||||
|
if (node) node.textContent = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgOf = (arr) => {
|
||||||
|
const nums = (arr || []).filter(v => Number.isFinite(v))
|
||||||
|
if (!nums.length) return null
|
||||||
|
return nums.reduce((a, b) => a + b, 0) / nums.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumOf = (arr) => (arr || []).reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||||
|
|
||||||
|
const firstValue = (arr) => {
|
||||||
|
for (let i = 0; i < arr.length; i += 1) {
|
||||||
|
if (Number.isFinite(arr[i])) return arr[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTrendData = (data) => ({
|
||||||
|
days: Number(el('daysRange').value || 90),
|
||||||
|
labels: data.trends.dates || [],
|
||||||
|
hr: data.trends.hr_avg || [],
|
||||||
|
resp: data.trends.resp_avg || [],
|
||||||
|
weight: data.trends.weight || [],
|
||||||
|
distance: data.trends.distance || [],
|
||||||
|
sleep: data.trends.sleep_hours || [],
|
||||||
|
stand: data.trends.stand_hours || [],
|
||||||
|
workout: data.trends.workout_minutes || [],
|
||||||
|
hrv: data.trends.hrv_sdnn || [],
|
||||||
|
restingHr: data.trends.resting_hr || [],
|
||||||
|
walkingHr: data.trends.walking_hr || [],
|
||||||
|
sleepRem: data.trends.sleep_rem || [],
|
||||||
|
sleepCore: data.trends.sleep_core || [],
|
||||||
|
sleepDeep: data.trends.sleep_deep || [],
|
||||||
|
sleepAwake: data.trends.sleep_awake || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const getTypeSummary = (data) => {
|
||||||
|
const shortType = (t) => String(t || '').replace('HKQuantityTypeIdentifier', '').replace('HKCategoryTypeIdentifier', '')
|
||||||
|
const types = data.compare.types || []
|
||||||
|
return {
|
||||||
|
typeLabels: types.map(t => shortType(t.type)),
|
||||||
|
typeValues: types.map(t => t.record_count || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSummary = (data) => {
|
||||||
|
setText('statRecords', data.summary.total_records || 0)
|
||||||
|
setText('statTypes', data.summary.type_count || 0)
|
||||||
|
setText('statRoutes', data.summary.route_count || 0)
|
||||||
|
setText('statEcg', data.summary.ecg_count || 0)
|
||||||
|
const dateRange = data.summary.date_min ? `${fmtDate(data.summary.date_min)} ~ ${fmtDate(data.summary.date_max)}` : '-'
|
||||||
|
setText('statDateRange', dateRange)
|
||||||
|
setText('statLastImport', data.summary.last_import ? `最近同步 ${fmtDate(data.summary.last_import)}` : '-')
|
||||||
|
setText('statAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||||
|
setText('statAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||||
|
setText('statDistance', fmtNum(data.cards.total_distance_km, 1))
|
||||||
|
const weightUnit = data.latest.weight_unit || 'kg'
|
||||||
|
const heightUnitRaw = data.latest.height_unit || 'cm'
|
||||||
|
let heightUnit = heightUnitRaw
|
||||||
|
let heightValue = data.latest.height_cm
|
||||||
|
if (heightUnitRaw === 'm' && Number.isFinite(heightValue)) {
|
||||||
|
heightValue = heightValue * 100
|
||||||
|
heightUnit = 'cm'
|
||||||
|
}
|
||||||
|
setText('statWeight', fmtNum(data.latest.weight_kg, 1))
|
||||||
|
setText('statHeight', fmtNum(heightValue, 1))
|
||||||
|
setText('statWeightUnit', weightUnit)
|
||||||
|
setText('statHeightUnit', heightUnit)
|
||||||
|
setText('statSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||||
|
setText('statStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||||
|
setText('statWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const destroyCharts = () => {
|
||||||
|
Object.values(state.charts).forEach(c => {
|
||||||
|
if (c && typeof c.destroy === 'function') c.destroy()
|
||||||
|
})
|
||||||
|
state.charts = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawSimpleLine = (canvas, labels, series, colors) => {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const width = canvas.clientWidth || canvas.width
|
||||||
|
const height = canvas.clientHeight || canvas.height
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
const allValues = series.flat().filter(v => Number.isFinite(v))
|
||||||
|
if (!allValues.length) return false
|
||||||
|
const min = Math.min(...allValues)
|
||||||
|
const max = Math.max(...allValues)
|
||||||
|
const pad = 24
|
||||||
|
const xStep = (width - pad * 2) / Math.max(1, labels.length - 1)
|
||||||
|
const scaleY = (v) => {
|
||||||
|
if (max === min) return height / 2
|
||||||
|
return height - pad - ((v - min) / (max - min)) * (height - pad * 2)
|
||||||
|
}
|
||||||
|
series.forEach((values, idx) => {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.strokeStyle = colors[idx] || '#38bdf8'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
values.forEach((v, i) => {
|
||||||
|
if (!Number.isFinite(v)) return
|
||||||
|
const x = pad + xStep * i
|
||||||
|
const y = scaleY(v)
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
})
|
||||||
|
ctx.stroke()
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawSimpleBars = (canvas, labels, values) => {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const width = canvas.clientWidth || canvas.width
|
||||||
|
const height = canvas.clientHeight || canvas.height
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
const nums = values.filter(v => Number.isFinite(v))
|
||||||
|
if (!nums.length) return false
|
||||||
|
const max = Math.max(...nums, 1)
|
||||||
|
const pad = 24
|
||||||
|
const barWidth = (width - pad * 2) / Math.max(1, values.length)
|
||||||
|
values.forEach((v, i) => {
|
||||||
|
if (!Number.isFinite(v)) return
|
||||||
|
const h = ((v / max) * (height - pad * 2))
|
||||||
|
const x = pad + i * barWidth + barWidth * 0.2
|
||||||
|
const y = height - pad - h
|
||||||
|
ctx.fillStyle = '#60a5fa'
|
||||||
|
ctx.fillRect(x, y, barWidth * 0.6, h)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawSimplePie = (canvas, values, colors) => {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const width = canvas.clientWidth || canvas.width
|
||||||
|
const height = canvas.clientHeight || canvas.height
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
const total = values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||||
|
if (!total) return false
|
||||||
|
let start = -Math.PI / 2
|
||||||
|
values.forEach((v, i) => {
|
||||||
|
const value = Number.isFinite(v) ? v : 0
|
||||||
|
const angle = (value / total) * Math.PI * 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(width / 2, height / 2)
|
||||||
|
ctx.fillStyle = colors[i % colors.length]
|
||||||
|
ctx.arc(width / 2, height / 2, Math.min(width, height) / 2 - 6, start, start + angle)
|
||||||
|
ctx.fill()
|
||||||
|
start += angle
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawSimpleScatter = (canvas, points, color = '#38bdf8') => {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const width = canvas.clientWidth || canvas.width
|
||||||
|
const height = canvas.clientHeight || canvas.height
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
if (!points.length) return false
|
||||||
|
const xs = points.map(p => p.x).filter(v => Number.isFinite(v))
|
||||||
|
const ys = points.map(p => p.y).filter(v => Number.isFinite(v))
|
||||||
|
if (!xs.length || !ys.length) return false
|
||||||
|
const minX = Math.min(...xs)
|
||||||
|
const maxX = Math.max(...xs)
|
||||||
|
const minY = Math.min(...ys)
|
||||||
|
const maxY = Math.max(...ys)
|
||||||
|
const pad = 24
|
||||||
|
const scaleX = (v) => {
|
||||||
|
if (maxX === minX) return width / 2
|
||||||
|
return pad + ((v - minX) / (maxX - minX)) * (width - pad * 2)
|
||||||
|
}
|
||||||
|
const scaleY = (v) => {
|
||||||
|
if (maxY === minY) return height / 2
|
||||||
|
return height - pad - ((v - minY) / (maxY - minY)) * (height - pad * 2)
|
||||||
|
}
|
||||||
|
ctx.fillStyle = color
|
||||||
|
points.forEach(p => {
|
||||||
|
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) return
|
||||||
|
const x = scaleX(p.x)
|
||||||
|
const y = scaleY(p.y)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, 3, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDefaultCharts = async (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
const { typeLabels, typeValues } = getTypeSummary(data)
|
||||||
|
const { days, labels, hr, resp, weight, distance, sleep, stand, workout, hrv, restingHr, walkingHr, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||||
|
destroyCharts()
|
||||||
|
const hasChart = await ensureChart()
|
||||||
|
if (hasChart) {
|
||||||
|
const vCtx = el('chartVitals').getContext('2d')
|
||||||
|
state.charts.vitals = new Chart(vCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||||
|
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: { x: { ticks: { maxTicksLimit: 6 } } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const wCtx = el('chartWeight').getContext('2d')
|
||||||
|
state.charts.weight = new Chart(wCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels, datasets: [{ label: '体重', data: weight, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const dCtx = el('chartDistance').getContext('2d')
|
||||||
|
state.charts.distance = new Chart(dCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#60a5fa' }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const tCtx = el('chartTypes').getContext('2d')
|
||||||
|
state.charts.types = new Chart(tCtx, {
|
||||||
|
type: 'pie',
|
||||||
|
data: {
|
||||||
|
labels: typeLabels,
|
||||||
|
datasets: [{ data: typeValues, backgroundColor: ['#38bdf8', '#60a5fa', '#a78bfa', '#f472b6', '#34d399', '#facc15', '#fb7185', '#c084fc', '#22d3ee', '#f97316', '#4ade80', '#eab308'] }]
|
||||||
|
},
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||||
|
})
|
||||||
|
const sCtx = el('chartSleep').getContext('2d')
|
||||||
|
state.charts.sleep = new Chart(sCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '睡眠小时', data: sleep, borderColor: '#34d399', backgroundColor: 'rgba(52,211,153,0.2)', tension: 0.35, pointRadius: 2 },
|
||||||
|
{ label: '站立小时', data: stand, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.2)', tension: 0.35, pointRadius: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const wCtx2 = el('chartWorkout').getContext('2d')
|
||||||
|
state.charts.workout = new Chart(wCtx2, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const ssCtx = el('chartSleepStages').getContext('2d')
|
||||||
|
state.charts.sleepStages = new Chart(ssCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||||
|
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||||
|
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||||
|
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { maxTicksLimit: 6 }, stacked: true },
|
||||||
|
y: { stacked: true, title: { display: true, text: '小时' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const hhCtx = el('chartHeartHealth').getContext('2d')
|
||||||
|
state.charts.heartHealth = new Chart(hhCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||||
|
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||||
|
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { maxTicksLimit: 6 } },
|
||||||
|
y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'bpm' } },
|
||||||
|
y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
drawSimpleLine(el('chartVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||||
|
drawSimpleLine(el('chartWeight'), labels, [weight], ['#a78bfa'])
|
||||||
|
drawSimpleBars(el('chartDistance'), labels, distance)
|
||||||
|
drawSimplePie(el('chartTypes'), typeValues, ['#38bdf8', '#60a5fa', '#a78bfa', '#f472b6', '#34d399', '#facc15', '#fb7185', '#c084fc', '#22d3ee', '#f97316', '#4ade80', '#eab308'])
|
||||||
|
drawSimpleLine(el('chartSleep'), labels, [sleep, stand], ['#34d399', '#f59e0b'])
|
||||||
|
drawSimpleBars(el('chartWorkout'), labels, workout)
|
||||||
|
}
|
||||||
|
setText('chartVitalsMeta', `${days}天内平均心率与呼吸率`)
|
||||||
|
setText('chartWeightMeta', `${days}天体重变化`)
|
||||||
|
setText('chartDistanceMeta', `${days}天步行距离`)
|
||||||
|
setText('chartTypesMeta', `最多记录的 ${typeLabels.length} 类数据`)
|
||||||
|
setText('chartSleepMeta', `${days}天睡眠小时与站立小时`)
|
||||||
|
setText('chartWorkoutMeta', `${days}天运动分钟`)
|
||||||
|
setText('chartSleepStagesMeta', `${days}天睡眠分期 (REM/核心/深睡/清醒)`)
|
||||||
|
setText('chartHeartHealthMeta', `${days}天静息/步行心率与 HRV 趋势`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeNameMap = {
|
||||||
|
HKQuantityTypeIdentifierStepCount: '步数',
|
||||||
|
HKQuantityTypeIdentifierActiveEnergyBurned: '活动能量',
|
||||||
|
HKQuantityTypeIdentifierAppleExerciseTime: '锻炼时间',
|
||||||
|
HKQuantityTypeIdentifierStandTime: '站立时间',
|
||||||
|
HKQuantityTypeIdentifierBasalEnergyBurned: '基础能量',
|
||||||
|
HKQuantityTypeIdentifierDistanceWalkingRunning: '步行跑步距离',
|
||||||
|
HKQuantityTypeIdentifierHeartRate: '心率',
|
||||||
|
HKQuantityTypeIdentifierRespiratoryRate: '呼吸率',
|
||||||
|
HKQuantityTypeIdentifierBodyMass: '体重',
|
||||||
|
HKQuantityTypeIdentifierHeight: '身高',
|
||||||
|
HKCategoryTypeIdentifierAppleStandHour: '站立小时',
|
||||||
|
HKCategoryTypeIdentifierSleepAnalysis: '睡眠'
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeDisplayConfig = {
|
||||||
|
HKQuantityTypeIdentifierStepCount: { mode: 'daily_avg', label: '日均' },
|
||||||
|
HKQuantityTypeIdentifierActiveEnergyBurned: { mode: 'daily_avg', label: '日均' },
|
||||||
|
HKQuantityTypeIdentifierBasalEnergyBurned: { mode: 'daily_avg', label: '日均' },
|
||||||
|
HKQuantityTypeIdentifierDistanceWalkingRunning: { mode: 'daily_avg', label: '日均' },
|
||||||
|
HKQuantityTypeIdentifierAppleExerciseTime: { mode: 'daily_avg', label: '日均' },
|
||||||
|
HKQuantityTypeIdentifierStandTime: { mode: 'daily_avg', label: '日均' },
|
||||||
|
HKQuantityTypeIdentifierFlightsClimbed: { mode: 'daily_avg', label: '日均' },
|
||||||
|
HKQuantityTypeIdentifierDietaryEnergyConsumed: { mode: 'daily_avg', label: '日均' },
|
||||||
|
HKQuantityTypeIdentifierHeartRate: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierRestingHeartRate: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierWalkingHeartRateAverage: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierHeartRateVariabilitySDNN: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierRespiratoryRate: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierOxygenSaturation: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierBodyMass: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierHeight: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierBodyTemperature: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierEnvironmentalAudioExposure: { mode: 'avg', label: '平均' },
|
||||||
|
HKQuantityTypeIdentifierHeadphoneAudioExposure: { mode: 'avg', label: '平均' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTypeList = (data, targetId = 'typeList') => {
|
||||||
|
const box = el(targetId)
|
||||||
|
if (!box) return
|
||||||
|
box.innerHTML = ''
|
||||||
|
const types = data.compare.types || []
|
||||||
|
if (!types.length) {
|
||||||
|
box.innerHTML = '<div class="list-item"><span class="muted">暂无类型数据</span></div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
types.slice(0, 12).forEach(item => {
|
||||||
|
const name = typeNameMap[item.type] || item.type.replace('HKQuantityTypeIdentifier', '').replace('HKCategoryTypeIdentifier', '')
|
||||||
|
const config = typeDisplayConfig[item.type] || { mode: 'count' }
|
||||||
|
let valueStr = ''
|
||||||
|
if (config.mode === 'daily_avg' && item.total_value && item.day_count) {
|
||||||
|
const val = item.total_value / Math.max(1, item.day_count)
|
||||||
|
valueStr = `${config.label} ${fmtNum(val, 1)}`
|
||||||
|
} else if (config.mode === 'avg' && item.avg_value) {
|
||||||
|
valueStr = `${config.label} ${fmtNum(item.avg_value, 1)}`
|
||||||
|
} else {
|
||||||
|
valueStr = `${item.record_count} 次`
|
||||||
|
}
|
||||||
|
const node = document.createElement('div')
|
||||||
|
node.className = 'list-item'
|
||||||
|
node.innerHTML = `
|
||||||
|
<div class="list-row">
|
||||||
|
<div class="list-title">${name}</div>
|
||||||
|
<div class="list-meta">${item.type}</div>
|
||||||
|
</div>
|
||||||
|
<span class="tag">${valueStr}${item.unit ? ` · ${item.unit}` : ''}</span>
|
||||||
|
`
|
||||||
|
box.appendChild(node)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastValue = (arr) => {
|
||||||
|
for (let i = arr.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (Number.isFinite(arr[i])) return arr[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTrendList = (data, targetId = 'trendList') => {
|
||||||
|
const box = el(targetId)
|
||||||
|
if (!box) return
|
||||||
|
box.innerHTML = ''
|
||||||
|
const hr = data.trends.hr_avg || []
|
||||||
|
const resp = data.trends.resp_avg || []
|
||||||
|
const weight = data.trends.weight || []
|
||||||
|
const distance = data.trends.distance || []
|
||||||
|
const sleep = data.trends.sleep_hours || []
|
||||||
|
const stand = data.trends.stand_hours || []
|
||||||
|
const workout = data.trends.workout_minutes || []
|
||||||
|
const days = Math.max(1, data.trends.dates.length || 1)
|
||||||
|
const totalDistance = distance.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||||
|
const avgDistance = totalDistance / days
|
||||||
|
const maxHr = Math.max(...hr.filter(v => Number.isFinite(v)), 0)
|
||||||
|
const lastHr = lastValue(hr)
|
||||||
|
const lastResp = lastValue(resp)
|
||||||
|
const lastWeight = lastValue(weight)
|
||||||
|
const lastSleep = lastValue(sleep)
|
||||||
|
const lastStand = lastValue(stand)
|
||||||
|
const lastWorkout = lastValue(workout)
|
||||||
|
const items = [
|
||||||
|
{ title: '最近心率', value: lastHr, unit: 'bpm' },
|
||||||
|
{ title: '最近呼吸率', value: lastResp, unit: '次/分钟' },
|
||||||
|
{ title: '最近体重', value: lastWeight, unit: 'kg' },
|
||||||
|
{ title: '最高心率', value: maxHr, unit: 'bpm' },
|
||||||
|
{ title: '日均步行距离', value: avgDistance, unit: 'km' },
|
||||||
|
{ title: '最近睡眠', value: lastSleep, unit: '小时' },
|
||||||
|
{ title: '最近站立', value: lastStand, unit: '小时' },
|
||||||
|
{ title: '最近运动', value: lastWorkout, unit: '分钟' }
|
||||||
|
]
|
||||||
|
const hasData = hr.some(v => Number.isFinite(v)) || resp.some(v => Number.isFinite(v)) || weight.some(v => Number.isFinite(v)) || distance.some(v => Number.isFinite(v)) || sleep.some(v => Number.isFinite(v)) || stand.some(v => Number.isFinite(v)) || workout.some(v => Number.isFinite(v))
|
||||||
|
if (!hasData) {
|
||||||
|
box.innerHTML = '<div class="list-item"><span class="muted">暂无趋势数据</span></div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.forEach(item => {
|
||||||
|
const node = document.createElement('div')
|
||||||
|
node.className = 'list-item'
|
||||||
|
node.innerHTML = `<span>${item.title}</span><span class="tag">${fmtNum(item.value, 1)} ${item.unit}</span>`
|
||||||
|
box.appendChild(node)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAnomalies = (data, targetId = 'anomalyList') => {
|
||||||
|
const box = el(targetId)
|
||||||
|
if (!box) return
|
||||||
|
box.innerHTML = ''
|
||||||
|
const items = []
|
||||||
|
data.anomalies.hr.forEach(a => items.push({ type: '心率', date: a.date, value: a.value, unit: 'bpm' }))
|
||||||
|
data.anomalies.resp.forEach(a => items.push({ type: '呼吸率', date: a.date, value: a.value, unit: '次/分钟' }))
|
||||||
|
if (!items.length) {
|
||||||
|
box.innerHTML = '<div class="list-item"><span class="muted">暂无异常高值</span></div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.slice(-10).reverse().forEach(item => {
|
||||||
|
const node = document.createElement('div')
|
||||||
|
node.className = 'list-item'
|
||||||
|
node.innerHTML = `<span>${item.type} ${fmtDate(item.date)}</span><span class="tag">${fmtNum(item.value, 1)} ${item.unit}</span>`
|
||||||
|
box.appendChild(node)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDeep = (data, targetId = 'deepList') => {
|
||||||
|
const box = el(targetId)
|
||||||
|
if (!box) return
|
||||||
|
box.innerHTML = ''
|
||||||
|
const routes = data.deep.routes || []
|
||||||
|
const ecg = data.deep.ecg || []
|
||||||
|
const workouts = data.deep.workouts || []
|
||||||
|
if (!routes.length && !ecg.length && !workouts.length) {
|
||||||
|
box.innerHTML = '<div class="list-item"><span class="muted">暂无深度数据</span></div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
workouts.forEach(r => {
|
||||||
|
const node = document.createElement('div')
|
||||||
|
node.className = 'list-item'
|
||||||
|
node.innerHTML = `<span>训练 ${fmtDate(r.start_time)}</span><span class="tag">${fmtNum(r.duration_min, 1)} 分钟 · ${fmtNum(r.distance_km, 2)} km</span>`
|
||||||
|
box.appendChild(node)
|
||||||
|
})
|
||||||
|
routes.forEach(r => {
|
||||||
|
const node = document.createElement('div')
|
||||||
|
node.className = 'list-item'
|
||||||
|
node.innerHTML = `<span>路线 ${fmtDate(r.route_time)}</span><span class="tag">${fmtNum(r.distance_km, 2)} km · ${fmtNum(r.duration_min, 1)} 分钟</span>`
|
||||||
|
box.appendChild(node)
|
||||||
|
})
|
||||||
|
ecg.forEach(r => {
|
||||||
|
const node = document.createElement('div')
|
||||||
|
node.className = 'list-item'
|
||||||
|
node.innerHTML = `<span>ECG ${fmtDate(r.record_time)}</span><span class="tag">${r.classification || '未知'} · ${fmtNum(r.sampling_rate, 0)} Hz</span>`
|
||||||
|
box.appendChild(node)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderHddSummary = (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
setText('hddAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||||
|
setText('hddAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||||
|
setText('hddAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||||
|
setText('hddAvgStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||||
|
setText('hddAvgWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||||
|
setText('hddDistance', fmtNum(sumOf(trends.distance), 1))
|
||||||
|
setText('hddRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||||
|
setText('hddHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAhaSummary = (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
const sleepTotal = sumOf(trends.sleepRem) + sumOf(trends.sleepCore) + sumOf(trends.sleepDeep) + sumOf(trends.sleepAwake)
|
||||||
|
const remPct = sleepTotal ? (sumOf(trends.sleepRem) / sleepTotal) * 100 : null
|
||||||
|
const deepPct = sleepTotal ? (sumOf(trends.sleepDeep) / sleepTotal) * 100 : null
|
||||||
|
const weightFirst = firstValue(trends.weight)
|
||||||
|
const weightLast = lastValue(trends.weight)
|
||||||
|
const weightDelta = Number.isFinite(weightFirst) && Number.isFinite(weightLast) ? weightLast - weightFirst : null
|
||||||
|
setText('ahaRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||||
|
setText('ahaWalkingHr', fmtNum(avgOf(trends.walkingHr), 1))
|
||||||
|
setText('ahaHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||||
|
setText('ahaRemPct', fmtNum(remPct, 1))
|
||||||
|
setText('ahaDeepPct', fmtNum(deepPct, 1))
|
||||||
|
setText('ahaWeightDelta', fmtNum(weightDelta, 2))
|
||||||
|
setText('ahaWorkoutTotal', fmtNum(sumOf(trends.workout), 0))
|
||||||
|
setText('ahaAnomalyCount', data.anomalies.hr ? data.anomalies.hr.length : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderGrafanaSummary = (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
setText('grafanaAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||||
|
setText('grafanaAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||||
|
setText('grafanaAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||||
|
setText('grafanaAvgWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||||
|
setText('grafanaAvgStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||||
|
setText('grafanaDistance', fmtNum(sumOf(trends.distance), 1))
|
||||||
|
setText('grafanaRoutes', data.summary.route_count || 0)
|
||||||
|
setText('grafanaEcg', data.summary.ecg_count || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDhdSummary = (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
setText('dhdAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||||
|
setText('dhdAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||||
|
setText('dhdRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||||
|
setText('dhdWalkingHr', fmtNum(avgOf(trends.walkingHr), 1))
|
||||||
|
setText('dhdHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||||
|
setText('dhdDistance', fmtNum(data.cards.total_distance_km, 1))
|
||||||
|
setText('dhdWeight', fmtNum(data.latest.weight_kg, 1))
|
||||||
|
setText('dhdWeightUnit', data.latest.weight_unit || 'kg')
|
||||||
|
setText('dhdAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderHddCharts = async (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
const { days, labels, hr, resp, workout, distance, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||||
|
destroyCharts()
|
||||||
|
const hasChart = await ensureChart()
|
||||||
|
if (hasChart) {
|
||||||
|
const vCtx = el('chartHddVitals').getContext('2d')
|
||||||
|
state.charts.hddVitals = new Chart(vCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||||
|
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: { responsive: true, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const aCtx = el('chartHddActivity').getContext('2d')
|
||||||
|
state.charts.hddActivity = new Chart(aCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const sCtx = el('chartHddSleepStages').getContext('2d')
|
||||||
|
state.charts.hddSleepStages = new Chart(sCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||||
|
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||||
|
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||||
|
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||||
|
})
|
||||||
|
const dCtx = el('chartHddDistance').getContext('2d')
|
||||||
|
state.charts.hddDistance = new Chart(dCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#60a5fa' }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
drawSimpleLine(el('chartHddVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||||
|
drawSimpleBars(el('chartHddActivity'), labels, workout)
|
||||||
|
drawSimpleBars(el('chartHddDistance'), labels, distance)
|
||||||
|
}
|
||||||
|
setText('chartHddVitalsMeta', `${days}天心率与呼吸率`)
|
||||||
|
setText('chartHddActivityMeta', `${days}天运动分钟`)
|
||||||
|
setText('chartHddSleepStagesMeta', `${days}天睡眠分期`)
|
||||||
|
setText('chartHddDistanceMeta', `${days}天步行距离`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAhaCharts = async (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
const { days, labels, weight, workout, hrv, restingHr, walkingHr, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||||
|
destroyCharts()
|
||||||
|
const hasChart = await ensureChart()
|
||||||
|
if (hasChart) {
|
||||||
|
const hCtx = el('chartAhaHeart').getContext('2d')
|
||||||
|
state.charts.ahaHeart = new Chart(hCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||||
|
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||||
|
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { maxTicksLimit: 6 } },
|
||||||
|
y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'bpm' } },
|
||||||
|
y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const sCtx = el('chartAhaSleepStages').getContext('2d')
|
||||||
|
state.charts.ahaSleepStages = new Chart(sCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||||
|
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||||
|
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||||
|
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||||
|
})
|
||||||
|
const wCtx = el('chartAhaWeight').getContext('2d')
|
||||||
|
state.charts.ahaWeight = new Chart(wCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels, datasets: [{ label: '体重', data: weight, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const aCtx = el('chartAhaWorkout').getContext('2d')
|
||||||
|
state.charts.ahaWorkout = new Chart(aCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
drawSimpleLine(el('chartAhaHeart'), labels, [restingHr, walkingHr], ['#f43f5e', '#ec4899'])
|
||||||
|
drawSimpleLine(el('chartAhaWeight'), labels, [weight], ['#a78bfa'])
|
||||||
|
drawSimpleBars(el('chartAhaWorkout'), labels, workout)
|
||||||
|
}
|
||||||
|
setText('chartAhaHeartMeta', `${days}天心脏恢复趋势`)
|
||||||
|
setText('chartAhaSleepStagesMeta', `${days}天睡眠结构`)
|
||||||
|
setText('chartAhaWeightMeta', `${days}天体重变化`)
|
||||||
|
setText('chartAhaWorkoutMeta', `${days}天运动分钟`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderGrafanaCharts = async (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
const { days, labels, workout, distance, sleepRem, sleepCore, sleepDeep, sleepAwake, restingHr, walkingHr, hrv } = trends
|
||||||
|
const routes = (data.deep && data.deep.routes) ? data.deep.routes : []
|
||||||
|
const { typeLabels, typeValues } = getTypeSummary(data)
|
||||||
|
const sleepTotals = [sumOf(sleepRem), sumOf(sleepCore), sumOf(sleepDeep), sumOf(sleepAwake)]
|
||||||
|
const sleepLabels = ['REM', '核心', '深睡', '清醒']
|
||||||
|
const routePoints = routes.map(r => ({
|
||||||
|
x: Number(r.distance_km || 0),
|
||||||
|
y: Number(r.duration_min || 0)
|
||||||
|
})).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
|
||||||
|
destroyCharts()
|
||||||
|
const hasChart = await ensureChart()
|
||||||
|
if (hasChart) {
|
||||||
|
const aCtx = el('chartGrafanaActivity').getContext('2d')
|
||||||
|
state.charts.grafanaActivity = new Chart(aCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ type: 'bar', label: '运动分钟', data: workout, backgroundColor: '#fb7185', yAxisID: 'y' },
|
||||||
|
{ type: 'line', label: '步行距离', data: distance, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { maxTicksLimit: 6 } },
|
||||||
|
y: { title: { display: true, text: '分钟' } },
|
||||||
|
y1: { position: 'right', title: { display: true, text: 'km' }, grid: { drawOnChartArea: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const sCtx = el('chartGrafanaSleepStages').getContext('2d')
|
||||||
|
state.charts.grafanaSleep = new Chart(sCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||||
|
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||||
|
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||||
|
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||||
|
})
|
||||||
|
const hCtx = el('chartGrafanaHeart').getContext('2d')
|
||||||
|
state.charts.grafanaHeart = new Chart(hCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||||
|
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||||
|
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { maxTicksLimit: 6 } },
|
||||||
|
y: { type: 'linear', position: 'left', title: { display: true, text: 'bpm' } },
|
||||||
|
y1: { type: 'linear', position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const tCtx = el('chartGrafanaTypes').getContext('2d')
|
||||||
|
state.charts.grafanaTypes = new Chart(tCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: typeLabels.slice(0, 8),
|
||||||
|
datasets: [{ data: typeValues.slice(0, 8), backgroundColor: ['#38bdf8', '#f472b6', '#facc15', '#34d399', '#a78bfa', '#fb7185', '#f97316', '#22d3ee'] }]
|
||||||
|
},
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||||
|
})
|
||||||
|
const rCtx = el('chartGrafanaRouteMap').getContext('2d')
|
||||||
|
state.charts.grafanaRoutes = new Chart(rCtx, {
|
||||||
|
type: 'scatter',
|
||||||
|
data: {
|
||||||
|
datasets: [{ label: '路线', data: routePoints, backgroundColor: '#22d3ee' }]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: '距离 km' } },
|
||||||
|
y: { title: { display: true, text: '时长 min' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const pCtx = el('chartGrafanaSleepPct').getContext('2d')
|
||||||
|
state.charts.grafanaSleepPct = new Chart(pCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: sleepLabels,
|
||||||
|
datasets: [{ data: sleepTotals, backgroundColor: ['#60a5fa', '#34d399', '#818cf8', '#fb923c'] }]
|
||||||
|
},
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
drawSimpleBars(el('chartGrafanaActivity'), labels, workout)
|
||||||
|
drawSimpleBars(el('chartGrafanaSleepStages'), labels, sleepRem)
|
||||||
|
drawSimpleLine(el('chartGrafanaHeart'), labels, [restingHr, walkingHr], ['#f43f5e', '#ec4899'])
|
||||||
|
drawSimplePie(el('chartGrafanaTypes'), typeValues.slice(0, 8), ['#38bdf8', '#f472b6', '#facc15', '#34d399', '#a78bfa', '#fb7185', '#f97316', '#22d3ee'])
|
||||||
|
drawSimpleScatter(el('chartGrafanaRouteMap'), routePoints, '#22d3ee')
|
||||||
|
drawSimplePie(el('chartGrafanaSleepPct'), sleepTotals, ['#60a5fa', '#34d399', '#818cf8', '#fb923c'])
|
||||||
|
}
|
||||||
|
setText('chartGrafanaActivityMeta', `${days}天活动趋势`)
|
||||||
|
setText('chartGrafanaSleepStagesMeta', `${days}天睡眠分期`)
|
||||||
|
setText('chartGrafanaHeartMeta', `${days}天心脏恢复`)
|
||||||
|
setText('chartGrafanaTypesMeta', '高频指标分布')
|
||||||
|
setText('chartGrafanaRouteMapMeta', routePoints.length ? '路线距离/时长分布' : '暂无路线数据')
|
||||||
|
setText('chartGrafanaSleepPctMeta', '睡眠分期占比')
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDhdCharts = async (data) => {
|
||||||
|
const trends = getTrendData(data)
|
||||||
|
const { days, labels, hr, resp, restingHr, hrv, sleep, distance } = trends
|
||||||
|
destroyCharts()
|
||||||
|
const hasChart = await ensureChart()
|
||||||
|
if (hasChart) {
|
||||||
|
const vCtx = el('chartDhdVitals').getContext('2d')
|
||||||
|
state.charts.dhdVitals = new Chart(vCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||||
|
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const rCtx = el('chartDhdRecovery').getContext('2d')
|
||||||
|
state.charts.dhdRecovery = new Chart(rCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||||
|
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { maxTicksLimit: 6 } },
|
||||||
|
y: { title: { display: true, text: 'bpm' } },
|
||||||
|
y1: { position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const sCtx = el('chartDhdSleep').getContext('2d')
|
||||||
|
state.charts.dhdSleep = new Chart(sCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels, datasets: [{ label: '睡眠时长', data: sleep, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
const dCtx = el('chartDhdDistance').getContext('2d')
|
||||||
|
state.charts.dhdDistance = new Chart(dCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#22d3ee' }] },
|
||||||
|
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
drawSimpleLine(el('chartDhdVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||||
|
drawSimpleLine(el('chartDhdRecovery'), labels, [restingHr, hrv], ['#f43f5e', '#a855f7'])
|
||||||
|
drawSimpleLine(el('chartDhdSleep'), labels, [sleep], ['#60a5fa'])
|
||||||
|
drawSimpleBars(el('chartDhdDistance'), labels, distance)
|
||||||
|
}
|
||||||
|
setText('chartDhdVitalsMeta', `${days}天心肺趋势`)
|
||||||
|
setText('chartDhdRecoveryMeta', `${days}天恢复指标`)
|
||||||
|
setText('chartDhdSleepMeta', `${days}天睡眠时长`)
|
||||||
|
setText('chartDhdDistanceMeta', `${days}天步行距离`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderActiveTab = async () => {
|
||||||
|
if (!state.lastData) return
|
||||||
|
if (state.activeTab === 'grafana') {
|
||||||
|
await renderGrafanaCharts(state.lastData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.activeTab === 'dhd') {
|
||||||
|
await renderDhdCharts(state.lastData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.activeTab === 'hdd') {
|
||||||
|
await renderHddCharts(state.lastData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.activeTab === 'aha') {
|
||||||
|
await renderAhaCharts(state.lastData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await renderDefaultCharts(state.lastData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupTabs = () => {
|
||||||
|
const tabs = Array.from(document.querySelectorAll('.tab-btn'))
|
||||||
|
const pages = Array.from(document.querySelectorAll('.tab-page'))
|
||||||
|
if (!tabs.length || !pages.length) return
|
||||||
|
const activate = (key) => {
|
||||||
|
tabs.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === key))
|
||||||
|
pages.forEach(page => page.classList.toggle('active', page.dataset.tabPage === key))
|
||||||
|
state.activeTab = key
|
||||||
|
renderActiveTab()
|
||||||
|
}
|
||||||
|
tabs.forEach(btn => btn.addEventListener('click', () => activate(btn.dataset.tab)))
|
||||||
|
const initialKey = tabs.find(btn => btn.classList.contains('active'))?.dataset.tab || tabs[0].dataset.tab
|
||||||
|
activate(initialKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
const overlay = el('loadingOverlay')
|
||||||
|
if (overlay) overlay.classList.add('visible')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const days = Number(el('daysRange').value || 90)
|
||||||
|
const res = await fetch(`/api/apple_watch/dashboard?days=${days}`)
|
||||||
|
const json = await res.json()
|
||||||
|
if (!json.ok) throw new Error(json.error || 'load_failed')
|
||||||
|
state.lastData = json.data
|
||||||
|
updateSummary(json.data)
|
||||||
|
renderHddSummary(json.data)
|
||||||
|
renderAhaSummary(json.data)
|
||||||
|
renderGrafanaSummary(json.data)
|
||||||
|
renderDhdSummary(json.data)
|
||||||
|
renderTypeList(json.data)
|
||||||
|
renderTrendList(json.data)
|
||||||
|
renderTypeList(json.data, 'hddTypeList')
|
||||||
|
renderTrendList(json.data, 'hddTrendList')
|
||||||
|
renderTypeList(json.data, 'grafanaTypeList')
|
||||||
|
renderTrendList(json.data, 'grafanaTrendList')
|
||||||
|
renderTypeList(json.data, 'dhdTypeList')
|
||||||
|
renderAnomalies(json.data)
|
||||||
|
renderDeep(json.data)
|
||||||
|
renderAnomalies(json.data, 'ahaAnomalyList')
|
||||||
|
renderDeep(json.data, 'ahaDeepList')
|
||||||
|
renderAnomalies(json.data, 'dhdAnomalyList')
|
||||||
|
await renderActiveTab()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert('加载失败: ' + e.message)
|
||||||
|
} finally {
|
||||||
|
if (overlay) overlay.classList.remove('visible')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runImport = async () => {
|
||||||
|
const res = await fetch('/api/apple_watch/import', { method: 'POST' })
|
||||||
|
const json = await res.json()
|
||||||
|
if (!json.ok) throw new Error(json.error || 'import_failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const refresh = async () => {
|
||||||
|
setText('statLastImport', '加载中')
|
||||||
|
await loadDashboard()
|
||||||
|
}
|
||||||
|
setupTabs()
|
||||||
|
el('btnRefresh').addEventListener('click', refresh)
|
||||||
|
el('daysRange').addEventListener('change', refresh)
|
||||||
|
el('btnImport').addEventListener('click', async () => {
|
||||||
|
setText('statLastImport', '同步中')
|
||||||
|
try {
|
||||||
|
await runImport()
|
||||||
|
await loadDashboard()
|
||||||
|
} catch {
|
||||||
|
setText('statLastImport', '同步失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Apple Watch 数据洞察</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<style>
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.loading-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(56, 189, 248, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #38bdf8;
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.loading-text {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loadingOverlay" class="loading-overlay">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="loading-text">正在处理数据...</div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<header class="header">
|
||||||
|
<div class="title">
|
||||||
|
<div class="title-main">Apple Watch 数据洞察</div>
|
||||||
|
<div class="title-sub">Apple Health 导出数据 · 近况与趋势</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<select id="daysRange">
|
||||||
|
<option value="30">近30天</option>
|
||||||
|
<option value="90" selected>近90天</option>
|
||||||
|
<option value="180">近180天</option>
|
||||||
|
<option value="365">近365天</option>
|
||||||
|
</select>
|
||||||
|
<button id="btnRefresh" class="primary">刷新</button>
|
||||||
|
<button id="btnImport" class="ghost">同步导出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs-bar">
|
||||||
|
<button class="tab-btn active" data-tab="default">Default Health</button>
|
||||||
|
<button class="tab-btn" data-tab="hdd">Health Data Dashboard</button>
|
||||||
|
<button class="tab-btn" data-tab="aha">Apple Health Analytics</button>
|
||||||
|
<button class="tab-btn" data-tab="grafana">Grafana Health</button>
|
||||||
|
<button class="tab-btn" data-tab="dhd">Digital Health Data</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-page active" data-tab-page="default">
|
||||||
|
<section class="cards" id="summaryCards">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">记录总量</div>
|
||||||
|
<div class="card-value" id="statRecords">0</div>
|
||||||
|
<div class="card-meta" id="statDateRange">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">数据类型</div>
|
||||||
|
<div class="card-value" id="statTypes">0</div>
|
||||||
|
<div class="card-meta" id="statLastImport">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">运动路线</div>
|
||||||
|
<div class="card-value" id="statRoutes">0</div>
|
||||||
|
<div class="card-meta">GPS 轨迹</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">ECG 记录</div>
|
||||||
|
<div class="card-value" id="statEcg">0</div>
|
||||||
|
<div class="card-meta">心电采样</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均心率</div>
|
||||||
|
<div class="card-value" id="statAvgHr">0</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均呼吸率</div>
|
||||||
|
<div class="card-value" id="statAvgResp">0</div>
|
||||||
|
<div class="card-meta">次/分钟</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">累计步行距离</div>
|
||||||
|
<div class="card-value" id="statDistance">0</div>
|
||||||
|
<div class="card-meta">公里</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">最近体重</div>
|
||||||
|
<div class="card-value" id="statWeight">-</div>
|
||||||
|
<div class="card-meta" id="statWeightUnit">kg</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">最近身高</div>
|
||||||
|
<div class="card-value" id="statHeight">-</div>
|
||||||
|
<div class="card-meta" id="statHeightUnit">cm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均睡眠</div>
|
||||||
|
<div class="card-value" id="statSleep">-</div>
|
||||||
|
<div class="card-meta">小时</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日站立小时</div>
|
||||||
|
<div class="card-value" id="statStand">-</div>
|
||||||
|
<div class="card-meta">小时</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日运动分钟</div>
|
||||||
|
<div class="card-value" id="statWorkout">-</div>
|
||||||
|
<div class="card-meta">分钟</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">心率与呼吸率趋势</div>
|
||||||
|
<canvas id="chartVitals"></canvas>
|
||||||
|
<div class="panel-foot" id="chartVitalsMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">体重趋势</div>
|
||||||
|
<canvas id="chartWeight"></canvas>
|
||||||
|
<div class="panel-foot" id="chartWeightMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">步行距离</div>
|
||||||
|
<canvas id="chartDistance"></canvas>
|
||||||
|
<div class="panel-foot" id="chartDistanceMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">高频数据类型</div>
|
||||||
|
<canvas id="chartTypes"></canvas>
|
||||||
|
<div class="panel-foot" id="chartTypesMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">睡眠与站立</div>
|
||||||
|
<canvas id="chartSleep"></canvas>
|
||||||
|
<div class="panel-foot" id="chartSleepMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">运动分钟趋势</div>
|
||||||
|
<canvas id="chartWorkout"></canvas>
|
||||||
|
<div class="panel-foot" id="chartWorkoutMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">睡眠分期</div>
|
||||||
|
<canvas id="chartSleepStages"></canvas>
|
||||||
|
<div class="panel-foot" id="chartSleepStagesMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">心脏健康</div>
|
||||||
|
<canvas id="chartHeartHealth"></canvas>
|
||||||
|
<div class="panel-foot" id="chartHeartHealthMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">类型榜单</div>
|
||||||
|
<div class="list" id="typeList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">趋势概览</div>
|
||||||
|
<div class="list" id="trendList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">异常提醒</div>
|
||||||
|
<div class="list" id="anomalyList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">深度数据</div>
|
||||||
|
<div class="list" id="deepList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-page" data-tab-page="hdd">
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均心率</div>
|
||||||
|
<div class="card-value" id="hddAvgHr">-</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均呼吸率</div>
|
||||||
|
<div class="card-value" id="hddAvgResp">-</div>
|
||||||
|
<div class="card-meta">次/分钟</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均睡眠</div>
|
||||||
|
<div class="card-value" id="hddAvgSleep">-</div>
|
||||||
|
<div class="card-meta">小时</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日站立小时</div>
|
||||||
|
<div class="card-value" id="hddAvgStand">-</div>
|
||||||
|
<div class="card-meta">小时</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日运动分钟</div>
|
||||||
|
<div class="card-value" id="hddAvgWorkout">-</div>
|
||||||
|
<div class="card-meta">分钟</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期步行距离</div>
|
||||||
|
<div class="card-value" id="hddDistance">-</div>
|
||||||
|
<div class="card-meta">公里</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期静息心率</div>
|
||||||
|
<div class="card-value" id="hddRestingHr">-</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期 HRV</div>
|
||||||
|
<div class="card-value" id="hddHrv">-</div>
|
||||||
|
<div class="card-meta">ms</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">心肺趋势</div>
|
||||||
|
<canvas id="chartHddVitals"></canvas>
|
||||||
|
<div class="panel-foot" id="chartHddVitalsMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">活动趋势</div>
|
||||||
|
<canvas id="chartHddActivity"></canvas>
|
||||||
|
<div class="panel-foot" id="chartHddActivityMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">睡眠分期</div>
|
||||||
|
<canvas id="chartHddSleepStages"></canvas>
|
||||||
|
<div class="panel-foot" id="chartHddSleepStagesMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">步行距离</div>
|
||||||
|
<canvas id="chartHddDistance"></canvas>
|
||||||
|
<div class="panel-foot" id="chartHddDistanceMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">类型榜单</div>
|
||||||
|
<div class="list" id="hddTypeList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">趋势概览</div>
|
||||||
|
<div class="list" id="hddTrendList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-page" data-tab-page="aha">
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期静息心率</div>
|
||||||
|
<div class="card-value" id="ahaRestingHr">-</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期步行心率</div>
|
||||||
|
<div class="card-value" id="ahaWalkingHr">-</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期 HRV</div>
|
||||||
|
<div class="card-value" id="ahaHrv">-</div>
|
||||||
|
<div class="card-meta">ms</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">REM 占比</div>
|
||||||
|
<div class="card-value" id="ahaRemPct">-</div>
|
||||||
|
<div class="card-meta">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">深睡占比</div>
|
||||||
|
<div class="card-value" id="ahaDeepPct">-</div>
|
||||||
|
<div class="card-meta">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">体重变化</div>
|
||||||
|
<div class="card-value" id="ahaWeightDelta">-</div>
|
||||||
|
<div class="card-meta">kg</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">运动分钟总量</div>
|
||||||
|
<div class="card-value" id="ahaWorkoutTotal">-</div>
|
||||||
|
<div class="card-meta">分钟</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">异常心率次数</div>
|
||||||
|
<div class="card-value" id="ahaAnomalyCount">-</div>
|
||||||
|
<div class="card-meta">次</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">心脏恢复趋势</div>
|
||||||
|
<canvas id="chartAhaHeart"></canvas>
|
||||||
|
<div class="panel-foot" id="chartAhaHeartMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">睡眠结构</div>
|
||||||
|
<canvas id="chartAhaSleepStages"></canvas>
|
||||||
|
<div class="panel-foot" id="chartAhaSleepStagesMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">体重趋势</div>
|
||||||
|
<canvas id="chartAhaWeight"></canvas>
|
||||||
|
<div class="panel-foot" id="chartAhaWeightMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">运动分钟</div>
|
||||||
|
<canvas id="chartAhaWorkout"></canvas>
|
||||||
|
<div class="panel-foot" id="chartAhaWorkoutMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">异常提醒</div>
|
||||||
|
<div class="list" id="ahaAnomalyList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">深度数据</div>
|
||||||
|
<div class="list" id="ahaDeepList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-page" data-tab-page="grafana">
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均心率</div>
|
||||||
|
<div class="card-value" id="grafanaAvgHr">-</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均呼吸率</div>
|
||||||
|
<div class="card-value" id="grafanaAvgResp">-</div>
|
||||||
|
<div class="card-meta">次/分钟</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均睡眠</div>
|
||||||
|
<div class="card-value" id="grafanaAvgSleep">-</div>
|
||||||
|
<div class="card-meta">小时</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日运动分钟</div>
|
||||||
|
<div class="card-value" id="grafanaAvgWorkout">-</div>
|
||||||
|
<div class="card-meta">分钟</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日站立小时</div>
|
||||||
|
<div class="card-value" id="grafanaAvgStand">-</div>
|
||||||
|
<div class="card-meta">小时</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期步行距离</div>
|
||||||
|
<div class="card-value" id="grafanaDistance">-</div>
|
||||||
|
<div class="card-meta">公里</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">运动路线</div>
|
||||||
|
<div class="card-value" id="grafanaRoutes">-</div>
|
||||||
|
<div class="card-meta">GPS 轨迹</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">ECG 记录</div>
|
||||||
|
<div class="card-value" id="grafanaEcg">-</div>
|
||||||
|
<div class="card-meta">心电采样</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">整体活动</div>
|
||||||
|
<canvas id="chartGrafanaActivity"></canvas>
|
||||||
|
<div class="panel-foot" id="chartGrafanaActivityMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">睡眠分期</div>
|
||||||
|
<canvas id="chartGrafanaSleepStages"></canvas>
|
||||||
|
<div class="panel-foot" id="chartGrafanaSleepStagesMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">心脏恢复</div>
|
||||||
|
<canvas id="chartGrafanaHeart"></canvas>
|
||||||
|
<div class="panel-foot" id="chartGrafanaHeartMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">指标分布</div>
|
||||||
|
<canvas id="chartGrafanaTypes"></canvas>
|
||||||
|
<div class="panel-foot" id="chartGrafanaTypesMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">路线地图</div>
|
||||||
|
<canvas id="chartGrafanaRouteMap"></canvas>
|
||||||
|
<div class="panel-foot" id="chartGrafanaRouteMapMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">睡眠占比</div>
|
||||||
|
<canvas id="chartGrafanaSleepPct"></canvas>
|
||||||
|
<div class="panel-foot" id="chartGrafanaSleepPctMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">类型榜单</div>
|
||||||
|
<div class="list" id="grafanaTypeList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">趋势概览</div>
|
||||||
|
<div class="list" id="grafanaTrendList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-page" data-tab-page="dhd">
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均心率</div>
|
||||||
|
<div class="card-value" id="dhdAvgHr">-</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均呼吸率</div>
|
||||||
|
<div class="card-value" id="dhdAvgResp">-</div>
|
||||||
|
<div class="card-meta">次/分钟</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期静息心率</div>
|
||||||
|
<div class="card-value" id="dhdRestingHr">-</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期步行心率</div>
|
||||||
|
<div class="card-value" id="dhdWalkingHr">-</div>
|
||||||
|
<div class="card-meta">bpm</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近周期 HRV</div>
|
||||||
|
<div class="card-value" id="dhdHrv">-</div>
|
||||||
|
<div class="card-meta">ms</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">累计步行距离</div>
|
||||||
|
<div class="card-value" id="dhdDistance">-</div>
|
||||||
|
<div class="card-meta">公里</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">最近体重</div>
|
||||||
|
<div class="card-value" id="dhdWeight">-</div>
|
||||||
|
<div class="card-meta" id="dhdWeightUnit">kg</div>
|
||||||
|
</div>
|
||||||
|
<div class="card accent">
|
||||||
|
<div class="card-label">近7日平均睡眠</div>
|
||||||
|
<div class="card-value" id="dhdAvgSleep">-</div>
|
||||||
|
<div class="card-meta">小时</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">心肺趋势</div>
|
||||||
|
<canvas id="chartDhdVitals"></canvas>
|
||||||
|
<div class="panel-foot" id="chartDhdVitalsMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">恢复趋势</div>
|
||||||
|
<canvas id="chartDhdRecovery"></canvas>
|
||||||
|
<div class="panel-foot" id="chartDhdRecoveryMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">睡眠时长</div>
|
||||||
|
<canvas id="chartDhdSleep"></canvas>
|
||||||
|
<div class="panel-foot" id="chartDhdSleepMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">步行距离</div>
|
||||||
|
<canvas id="chartDhdDistance"></canvas>
|
||||||
|
<div class="panel-foot" id="chartDhdDistanceMeta"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">类型榜单</div>
|
||||||
|
<div class="list" id="dhdTypeList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">异常提醒</div>
|
||||||
|
<div class="list" id="dhdAnomalyList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft Yahei", sans-serif;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 24px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-main {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-sub {
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-page {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-page.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 16px;
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.accent {
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(15, 118, 110, 0.2));
|
||||||
|
border-color: rgba(37, 99, 235, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #0b1220;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 260px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-foot {
|
||||||
|
margin-top: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page {
|
||||||
|
padding: 20px 16px 40px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tabs-bar {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Microsoft YaHei", Arial, sans-serif;
|
||||||
|
background: #f3f6fb;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-card {
|
||||||
|
border: 1px solid #e5edf6;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e0ecff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-actions.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid #c9d7eb;
|
||||||
|
background: #fff;
|
||||||
|
color: #1f2937;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #cdd8e6;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
min-height: 140px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-block {
|
||||||
|
margin-top: 14px;
|
||||||
|
border: 1px solid #e5edf6;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dbe7f7;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-inner {
|
||||||
|
width: 0%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #2563eb, #38bdf8);
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-box {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #dbeafe;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 110px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #e5edf6;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-wrap {
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-wrap {
|
||||||
|
max-height: 220px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-wrap {
|
||||||
|
max-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #f8fbff;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid,
|
||||||
|
.textarea-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-grid {
|
||||||
|
margin-top: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-inline {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-text {
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sep {
|
||||||
|
margin: 0 6px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #166534;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-err {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
(() => {
|
||||||
|
const el = id => document.getElementById(id)
|
||||||
|
let currentConfig = null
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
const esc = s => String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
|
||||||
|
const setNotice = (text, isError) => {
|
||||||
|
const box = el('save-notice')
|
||||||
|
if (!box) return
|
||||||
|
if (!text) {
|
||||||
|
box.style.display = 'none'
|
||||||
|
box.textContent = ''
|
||||||
|
box.className = 'notice'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
box.style.display = ''
|
||||||
|
box.textContent = text
|
||||||
|
box.className = isError ? 'notice notice-err' : 'notice'
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAuthWarning = text => {
|
||||||
|
const box = el('auth-warning')
|
||||||
|
if (!box) return
|
||||||
|
if (!text) {
|
||||||
|
box.style.display = 'none'
|
||||||
|
box.textContent = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
box.style.display = ''
|
||||||
|
box.textContent = text
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJson = async url => {
|
||||||
|
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}_ts=${Date.now()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
const data = await resp.json().catch(() => null)
|
||||||
|
if (resp.status === 401) throw new Error('UNAUTHORIZED')
|
||||||
|
if (!resp.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${resp.status}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const postJson = async (url, body) => {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(body || {})
|
||||||
|
})
|
||||||
|
const data = await resp.json().catch(() => null)
|
||||||
|
if (resp.status === 401) throw new Error('UNAUTHORIZED')
|
||||||
|
if (!resp.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${resp.status}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatStatus = status => {
|
||||||
|
const map = {
|
||||||
|
success: '成功',
|
||||||
|
partial_success: '部分成功',
|
||||||
|
failed: '失败',
|
||||||
|
running: '运行中',
|
||||||
|
idle: '空闲'
|
||||||
|
}
|
||||||
|
return map[String(status || '')] || (status || '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = bytes => {
|
||||||
|
const n = Number(bytes || 0)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let value = n
|
||||||
|
let idx = 0
|
||||||
|
while (value >= 1024 && idx < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
return `${value >= 100 || idx === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[idx]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillLines = (id, values) => {
|
||||||
|
const node = el(id)
|
||||||
|
if (!node) return
|
||||||
|
node.value = Array.isArray(values) ? values.join('\n') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const readLines = id => {
|
||||||
|
const node = el(id)
|
||||||
|
return String((node && node.value) || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderOverview = data => {
|
||||||
|
const high = data.highfreq || {}
|
||||||
|
const low = data.lowfreq || {}
|
||||||
|
const current = data.current_run || {}
|
||||||
|
const manual = data.manual_baidu_backup || {}
|
||||||
|
const scheduler = data.scheduler || {}
|
||||||
|
const verify = data.verify || {}
|
||||||
|
const restore = data.restore || {}
|
||||||
|
|
||||||
|
el('highfreq-status').textContent = formatStatus(high.last_status || '-')
|
||||||
|
el('highfreq-last').textContent = `上次:${high.last_run_at || '-'}`
|
||||||
|
el('highfreq-next').textContent = `下次:${scheduler.next_highfreq_at || '-'}`
|
||||||
|
|
||||||
|
el('lowfreq-status').textContent = formatStatus(low.last_status || '-')
|
||||||
|
el('lowfreq-last').textContent = `上次:${low.last_run_at || '-'}`
|
||||||
|
el('lowfreq-next').textContent = `下次:${scheduler.next_lowfreq_at || '-'}`
|
||||||
|
|
||||||
|
el('current-run-status').textContent = formatStatus(current.status || 'idle')
|
||||||
|
el('current-run-stage').textContent = `阶段:${current.stage || '-'}`
|
||||||
|
el('current-run-message').textContent = `消息:${current.message || '-'}`
|
||||||
|
|
||||||
|
el('manual-last').textContent = manual.last_recorded_at || '-'
|
||||||
|
el('oss-ready').textContent = data.oss_ready ? 'OSS状态:已就绪' : 'OSS状态:未就绪'
|
||||||
|
el('verify-last').textContent = `最近抽检:${verify.last_run_at ? `${formatStatus(verify.last_status || '-')} / ${verify.last_run_at}` : '-'}`
|
||||||
|
el('restore-last').textContent = `最近演练:${restore.last_run_at ? `${formatStatus(restore.last_status || '-')} / ${restore.last_run_at}` : '-'}`
|
||||||
|
el('credential-file').textContent = `凭证文件:${data.credential_file || '-'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCurrentRun = run => {
|
||||||
|
const current = run || {}
|
||||||
|
const percent = Math.max(0, Math.min(100, Number(current.percent || 0)))
|
||||||
|
el('progress-title').textContent = current.run_id ? `${formatStatus(current.status)} / ${current.mode || '-'}` : '当前无任务'
|
||||||
|
el('progress-percent').textContent = `${percent}%`
|
||||||
|
el('progress-inner').style.width = `${percent}%`
|
||||||
|
el('progress-detail').textContent = current.message || '等待任务启动'
|
||||||
|
const summary = current.stats && Object.keys(current.stats).length
|
||||||
|
? JSON.stringify(current.stats, null, 2)
|
||||||
|
: '暂无结果'
|
||||||
|
el('run-summary').textContent = summary
|
||||||
|
|
||||||
|
const running = current.status === 'running'
|
||||||
|
;['btn-run-high', 'btn-run-low', 'btn-run-all', 'btn-run-verify', 'btn-run-restore', 'btn-manual-record', 'btn-config-save'].forEach(id => {
|
||||||
|
const node = el(id)
|
||||||
|
if (node) node.disabled = running
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAlerts = rows => {
|
||||||
|
const body = el('alerts-body')
|
||||||
|
if (!body) return
|
||||||
|
body.innerHTML = ''
|
||||||
|
const list = Array.isArray(rows) ? rows : []
|
||||||
|
if (!list.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="muted">暂无告警</td></tr>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.innerHTML = list.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(item.created_at || '')}</td>
|
||||||
|
<td>${esc(item.severity || '')}</td>
|
||||||
|
<td>${esc(item.title || '')}</td>
|
||||||
|
<td>${esc(item.message || '')}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderLogs = rows => {
|
||||||
|
const body = el('logs-body')
|
||||||
|
if (!body) return
|
||||||
|
body.innerHTML = ''
|
||||||
|
const list = Array.isArray(rows) ? rows : []
|
||||||
|
if (!list.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="muted">暂无日志</td></tr>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.innerHTML = list.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(item.created_at || '')}</td>
|
||||||
|
<td>${esc(item.mode || '')}</td>
|
||||||
|
<td>${esc(item.event_type || '')}</td>
|
||||||
|
<td>${esc(item.message || '')}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCatalog = payload => {
|
||||||
|
const highBody = el('catalog-highfreq-body')
|
||||||
|
const lowBody = el('catalog-lowfreq-body')
|
||||||
|
if (!highBody || !lowBody) return
|
||||||
|
const highfreqRuns = Array.isArray(payload.highfreq_runs) ? payload.highfreq_runs : []
|
||||||
|
const lowfreqObjects = Array.isArray(payload.lowfreq_objects) ? payload.lowfreq_objects : []
|
||||||
|
|
||||||
|
if (!highfreqRuns.length) {
|
||||||
|
highBody.innerHTML = '<tr><td colspan="6" class="muted">暂无高频批次</td></tr>'
|
||||||
|
} else {
|
||||||
|
highBody.innerHTML = highfreqRuns.map(item => {
|
||||||
|
const high = item.highfreq || {}
|
||||||
|
const key = esc(high.oss_key || '-')
|
||||||
|
const runGuid = esc(high.run_guid || '-')
|
||||||
|
const info = `${Number(high.file_count || 0)} 文件 / ${formatSize(high.zip_size_bytes || 0)}`
|
||||||
|
const time = esc(item.ended_at || item.started_at || '')
|
||||||
|
const status = esc(formatStatus(item.status || ''))
|
||||||
|
const href = `/api/box_backup/download/highfreq?id=${encodeURIComponent(item.id)}`
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${time || '-'}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
<td><div class="key-text">${runGuid}</div></td>
|
||||||
|
<td>${esc(info)}</td>
|
||||||
|
<td><div class="key-text">${key}</div></td>
|
||||||
|
<td><a class="btn-link" href="${href}">下载归档</a></td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lowfreqObjects.length) {
|
||||||
|
lowBody.innerHTML = '<tr><td colspan="6" class="muted">暂无低频对象</td></tr>'
|
||||||
|
} else {
|
||||||
|
lowBody.innerHTML = lowfreqObjects.map(item => {
|
||||||
|
const meta = item.meta || {}
|
||||||
|
const encryption = item.encryption || {}
|
||||||
|
const storedName = esc(meta.stored_name || '-')
|
||||||
|
const key = esc(item.oss_key || '-')
|
||||||
|
const relPath = esc(item.relative_path || '-')
|
||||||
|
const time = esc(item.last_uploaded_at || item.deleted_at || '')
|
||||||
|
const statusTextBase = item.is_deleted ? `${formatStatus(item.upload_status || '')} / 已物理删除` : formatStatus(item.upload_status || '')
|
||||||
|
const statusText = encryption.encrypted ? `${statusTextBase} / ${encryption.label || '加密文件'}` : statusTextBase
|
||||||
|
const action = String(item.upload_status || '') === 'uploaded'
|
||||||
|
? `${encryption.encrypted ? `<a class="btn-link" href="/api/box_backup/download/lowfreq_decrypted?id=${encodeURIComponent(item.id)}">解密下载</a><span class="action-sep">|</span>` : ''}<a class="btn-link" href="/api/box_backup/download/lowfreq?id=${encodeURIComponent(item.id)}">${encryption.encrypted ? '原件下载' : '下载文件'}</a>`
|
||||||
|
: `<span class="muted">${esc(item.last_error || '-')}</span>`
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${time || '-'}</td>
|
||||||
|
<td>${esc(statusText)}</td>
|
||||||
|
<td><div class="key-text">${relPath}</div></td>
|
||||||
|
<td><div class="key-text">${storedName}</div></td>
|
||||||
|
<td><div class="key-text">${key}</div></td>
|
||||||
|
<td>${action}</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderConfig = payload => {
|
||||||
|
currentConfig = payload.config || {}
|
||||||
|
const cfg = currentConfig
|
||||||
|
const status = payload.credential_status || {}
|
||||||
|
el('cfg-high-cron').value = ((cfg.scheduler || {}).highfreq_cron) || ''
|
||||||
|
el('cfg-low-cron').value = ((cfg.scheduler || {}).lowfreq_cron) || ''
|
||||||
|
el('cfg-poll-seconds').value = ((cfg.scheduler || {}).poll_seconds) || 30
|
||||||
|
el('cfg-oss-root-prefix').value = ((cfg.oss || {}).root_prefix) || status.root_prefix || ''
|
||||||
|
el('cfg-oss-root-prefix-key').value = ((cfg.oss || {}).root_prefix_key) || 'BOX_BACKUP_OSS_ROOT_PREFIX'
|
||||||
|
el('cfg-lowfreq-dir-name').value = ((cfg.oss || {}).lowfreq_dir_name) || status.lowfreq_dir_name || ''
|
||||||
|
el('cfg-oss-bucket').value = ((cfg.oss || {}).bucket) || ''
|
||||||
|
el('cfg-oss-endpoint').value = ((cfg.oss || {}).endpoint) || ''
|
||||||
|
el('cfg-credential-file').value = ((cfg.oss || {}).credential_file_name) || ''
|
||||||
|
el('cfg-ak-key').value = ((cfg.oss || {}).access_key_id_key) || ''
|
||||||
|
el('cfg-sk-key').value = ((cfg.oss || {}).access_key_secret_key) || ''
|
||||||
|
el('cfg-bucket-key').value = ((cfg.oss || {}).bucket_key) || ''
|
||||||
|
el('cfg-endpoint-key').value = ((cfg.oss || {}).endpoint_key) || ''
|
||||||
|
el('cfg-sample-size').value = ((cfg.verification || {}).lowfreq_sample_size) || 3
|
||||||
|
el('cfg-restore-max-mb').value = ((cfg.verification || {}).restore_max_download_mb) || 2048
|
||||||
|
fillLines('cfg-high-code-paths', (((cfg.paths || {}).highfreq || {}).code_paths) || [])
|
||||||
|
fillLines('cfg-high-root-files', (((cfg.paths || {}).highfreq || {}).root_files) || [])
|
||||||
|
fillLines('cfg-high-exts', (((cfg.paths || {}).highfreq || {}).tool_code_extensions) || [])
|
||||||
|
fillLines('cfg-low-paths', (((cfg.paths || {}).lowfreq || {}).include_paths) || [])
|
||||||
|
fillLines('cfg-high-excludes', (((cfg.paths || {}).highfreq || {}).exclude_dirs) || [])
|
||||||
|
fillLines('cfg-low-excludes', (((cfg.paths || {}).lowfreq || {}).exclude_dirs) || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectConfig = () => {
|
||||||
|
const cfg = JSON.parse(JSON.stringify(currentConfig || {}))
|
||||||
|
cfg.scheduler = cfg.scheduler || {}
|
||||||
|
cfg.paths = cfg.paths || {}
|
||||||
|
cfg.paths.highfreq = cfg.paths.highfreq || {}
|
||||||
|
cfg.paths.lowfreq = cfg.paths.lowfreq || {}
|
||||||
|
cfg.oss = cfg.oss || {}
|
||||||
|
|
||||||
|
cfg.scheduler.highfreq_cron = el('cfg-high-cron').value.trim()
|
||||||
|
cfg.scheduler.lowfreq_cron = el('cfg-low-cron').value.trim()
|
||||||
|
cfg.scheduler.poll_seconds = Number(el('cfg-poll-seconds').value || 30)
|
||||||
|
cfg.oss.root_prefix = el('cfg-oss-root-prefix').value.trim()
|
||||||
|
cfg.oss.root_prefix_key = el('cfg-oss-root-prefix-key').value.trim()
|
||||||
|
cfg.oss.lowfreq_dir_name = el('cfg-lowfreq-dir-name').value.trim()
|
||||||
|
cfg.oss.bucket = el('cfg-oss-bucket').value.trim()
|
||||||
|
cfg.oss.endpoint = el('cfg-oss-endpoint').value.trim()
|
||||||
|
cfg.oss.credential_file_name = el('cfg-credential-file').value.trim()
|
||||||
|
cfg.oss.access_key_id_key = el('cfg-ak-key').value.trim()
|
||||||
|
cfg.oss.access_key_secret_key = el('cfg-sk-key').value.trim()
|
||||||
|
cfg.oss.bucket_key = el('cfg-bucket-key').value.trim()
|
||||||
|
cfg.oss.endpoint_key = el('cfg-endpoint-key').value.trim()
|
||||||
|
cfg.verification = cfg.verification || {}
|
||||||
|
cfg.verification.lowfreq_sample_size = Number(el('cfg-sample-size').value || 3)
|
||||||
|
cfg.verification.restore_max_download_mb = Number(el('cfg-restore-max-mb').value || 2048)
|
||||||
|
cfg.paths.highfreq.code_paths = readLines('cfg-high-code-paths')
|
||||||
|
cfg.paths.highfreq.root_files = readLines('cfg-high-root-files')
|
||||||
|
cfg.paths.highfreq.tool_code_extensions = readLines('cfg-high-exts')
|
||||||
|
cfg.paths.lowfreq.include_paths = readLines('cfg-low-paths')
|
||||||
|
cfg.paths.highfreq.exclude_dirs = readLines('cfg-high-excludes')
|
||||||
|
cfg.paths.lowfreq.exclude_dirs = readLines('cfg-low-excludes')
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshOverview = async () => {
|
||||||
|
const [overview, currentRun, alerts] = await Promise.all([
|
||||||
|
getJson('/api/box_backup/overview'),
|
||||||
|
getJson('/api/box_backup/current_run'),
|
||||||
|
getJson('/api/box_backup/alerts?limit=30')
|
||||||
|
])
|
||||||
|
renderOverview(overview)
|
||||||
|
renderCurrentRun(currentRun.run)
|
||||||
|
renderAlerts(alerts.rows || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshLogs = async () => {
|
||||||
|
const failuresOnly = !!(el('filter-failures') && el('filter-failures').checked)
|
||||||
|
const logs = await getJson(`/api/box_backup/events?limit=200${failuresOnly ? '&failures_only=1' : ''}`)
|
||||||
|
renderLogs(logs.rows || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshConfig = async () => {
|
||||||
|
const payload = await getJson('/api/box_backup/config')
|
||||||
|
renderConfig(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshCatalog = async () => {
|
||||||
|
const status = el('catalog-status-filter') ? el('catalog-status-filter').value.trim() : ''
|
||||||
|
const payload = await getJson(`/api/box_backup/catalog?run_limit=20&object_limit=200${status ? `&status=${encodeURIComponent(status)}` : ''}`)
|
||||||
|
renderCatalog(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAll = async () => {
|
||||||
|
try {
|
||||||
|
setAuthWarning('')
|
||||||
|
await Promise.all([refreshOverview(), refreshLogs(), refreshConfig(), refreshCatalog()])
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error.message || error) === 'UNAUTHORIZED') {
|
||||||
|
setAuthWarning('未授权。请从带 token 的入口进入,或先完成导航鉴权。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setNotice(String(error.message || error), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runMode = async mode => {
|
||||||
|
try {
|
||||||
|
setNotice('', false)
|
||||||
|
await postJson(`/api/box_backup/run/${mode}`, {})
|
||||||
|
await Promise.all([refreshOverview(), refreshLogs(), refreshCatalog()])
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(String(error.message || error), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
try {
|
||||||
|
const cfg = collectConfig()
|
||||||
|
const payload = await postJson('/api/box_backup/config/save', { config: cfg })
|
||||||
|
renderConfig(payload)
|
||||||
|
setNotice('配置已保存并热生效', false)
|
||||||
|
await Promise.all([refreshOverview(), refreshCatalog()])
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(String(error.message || error), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordManual = async () => {
|
||||||
|
try {
|
||||||
|
await postJson('/api/box_backup/manual_baidu_backup', {
|
||||||
|
note: el('manual-note').value.trim()
|
||||||
|
})
|
||||||
|
el('manual-note').value = ''
|
||||||
|
setNotice('已记录手工百度云盘备份时间', false)
|
||||||
|
await refreshOverview()
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(String(error.message || error), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await refreshOverview()
|
||||||
|
await refreshLogs()
|
||||||
|
await refreshCatalog()
|
||||||
|
} catch {}
|
||||||
|
}, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bind = () => {
|
||||||
|
el('btn-refresh').onclick = refreshAll
|
||||||
|
el('btn-run-high').onclick = () => runMode('high')
|
||||||
|
el('btn-run-low').onclick = () => runMode('low')
|
||||||
|
el('btn-run-all').onclick = () => runMode('all')
|
||||||
|
el('btn-run-verify').onclick = () => runMode('verify')
|
||||||
|
el('btn-run-restore').onclick = () => runMode('restore')
|
||||||
|
el('btn-config-reload').onclick = refreshConfig
|
||||||
|
el('btn-config-save').onclick = saveConfig
|
||||||
|
el('btn-manual-record').onclick = recordManual
|
||||||
|
el('filter-failures').onchange = refreshLogs
|
||||||
|
el('btn-catalog-refresh').onclick = refreshCatalog
|
||||||
|
el('catalog-status-filter').onchange = refreshCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
bind()
|
||||||
|
await refreshAll()
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BOX容灾哨兵</title>
|
||||||
|
<link rel="stylesheet" href="/tools/box_disaster_sentinel/box_disaster_sentinel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header class="hero">
|
||||||
|
<div>
|
||||||
|
<h1>BOX容灾哨兵</h1>
|
||||||
|
<p>OSS 二线备份控制台:状态、操作、告警、配置一体化</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button id="btn-refresh" class="btn btn-primary">刷新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="auth-warning" class="notice notice-err" style="display:none;"></div>
|
||||||
|
<div id="save-notice" class="notice" style="display:none;"></div>
|
||||||
|
|
||||||
|
<main class="grid">
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>1. 状态总览</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overview-grid">
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="mini-title">高频备份</div>
|
||||||
|
<div id="highfreq-status" class="status-text">-</div>
|
||||||
|
<div id="highfreq-last" class="muted">上次:-</div>
|
||||||
|
<div id="highfreq-next" class="muted">下次:-</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="mini-title">低频备份</div>
|
||||||
|
<div id="lowfreq-status" class="status-text">-</div>
|
||||||
|
<div id="lowfreq-last" class="muted">上次:-</div>
|
||||||
|
<div id="lowfreq-next" class="muted">下次:-</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="mini-title">当前任务</div>
|
||||||
|
<div id="current-run-status" class="status-text">空闲</div>
|
||||||
|
<div id="current-run-stage" class="muted">阶段:-</div>
|
||||||
|
<div id="current-run-message" class="muted">消息:-</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="mini-title">百度云盘手工备份</div>
|
||||||
|
<div id="manual-last" class="status-text">-</div>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<input id="manual-note" class="input" placeholder="备注,可选">
|
||||||
|
<button id="btn-manual-record" class="btn">我已完成</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span id="oss-ready" class="pill">OSS状态:-</span>
|
||||||
|
<span id="verify-last" class="pill">最近抽检:-</span>
|
||||||
|
<span id="restore-last" class="pill">最近演练:-</span>
|
||||||
|
<span id="credential-file" class="muted">凭证文件:-</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>2. 备份操作</h2>
|
||||||
|
</div>
|
||||||
|
<div class="inline-actions wrap">
|
||||||
|
<button id="btn-run-high" class="btn btn-primary">手动触发高频</button>
|
||||||
|
<button id="btn-run-low" class="btn btn-primary">手动触发低频</button>
|
||||||
|
<button id="btn-run-all" class="btn btn-primary">手动触发全部</button>
|
||||||
|
<button id="btn-run-verify" class="btn">回读抽检</button>
|
||||||
|
<button id="btn-run-restore" class="btn">恢复演练</button>
|
||||||
|
</div>
|
||||||
|
<div class="progress-block">
|
||||||
|
<div class="progress-head">
|
||||||
|
<span id="progress-title">当前无任务</span>
|
||||||
|
<span id="progress-percent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div id="progress-inner" class="progress-inner"></div>
|
||||||
|
</div>
|
||||||
|
<div id="progress-detail" class="muted">等待任务启动</div>
|
||||||
|
</div>
|
||||||
|
<pre id="run-summary" class="summary-box">暂无结果</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>3. 告警 & 日志</h2>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="filter-failures" type="checkbox">
|
||||||
|
<span>只看失败/重试</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap alerts-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>级别</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>内容</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="alerts-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap logs-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>模式</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>内容</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="logs-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>4. 备份清单</h2>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<label class="field field-inline">
|
||||||
|
<span>低频状态</span>
|
||||||
|
<select id="catalog-status-filter" class="input">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="uploaded">已上传</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
<option value="pending">待上传</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="btn-catalog-refresh" class="btn">刷新清单</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="catalog-grid">
|
||||||
|
<div>
|
||||||
|
<div class="section-subtitle">高频批次</div>
|
||||||
|
<div class="table-wrap catalog-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>Run GUID</th>
|
||||||
|
<th>内容</th>
|
||||||
|
<th>OSS Key</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="catalog-highfreq-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="section-subtitle">低频对象</div>
|
||||||
|
<div class="table-wrap catalog-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>原始路径</th>
|
||||||
|
<th>存储名</th>
|
||||||
|
<th>OSS Key</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="catalog-lowfreq-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>5. 配置管理</h2>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<button id="btn-config-reload" class="btn">重新读取</button>
|
||||||
|
<button id="btn-config-save" class="btn btn-primary">保存并热生效</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>高频 cron</span>
|
||||||
|
<input id="cfg-high-cron" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>低频 cron</span>
|
||||||
|
<input id="cfg-low-cron" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>轮询秒数</span>
|
||||||
|
<input id="cfg-poll-seconds" class="input" type="number" min="10">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>OSS 根目录</span>
|
||||||
|
<input id="cfg-oss-root-prefix" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>根目录 Key 名</span>
|
||||||
|
<input id="cfg-oss-root-prefix-key" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>低频固定目录</span>
|
||||||
|
<input id="cfg-lowfreq-dir-name" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>OSS Bucket</span>
|
||||||
|
<input id="cfg-oss-bucket" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>OSS Endpoint</span>
|
||||||
|
<input id="cfg-oss-endpoint" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>凭证文件名</span>
|
||||||
|
<input id="cfg-credential-file" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>AK Key 名</span>
|
||||||
|
<input id="cfg-ak-key" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>SK Key 名</span>
|
||||||
|
<input id="cfg-sk-key" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Bucket Key 名</span>
|
||||||
|
<input id="cfg-bucket-key" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Endpoint Key 名</span>
|
||||||
|
<input id="cfg-endpoint-key" class="input">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>通知方式</span>
|
||||||
|
<input id="cfg-notification-placeholder" class="input" value="第一版预留,暂不启用" readonly>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>抽检样本数</span>
|
||||||
|
<input id="cfg-sample-size" class="input" type="number" min="1">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>恢复演练最大下载(MB)</span>
|
||||||
|
<input id="cfg-restore-max-mb" class="input" type="number" min="128">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="textarea-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>高频代码路径(每行一个)</span>
|
||||||
|
<textarea id="cfg-high-code-paths" class="textarea"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>高频根文件(每行一个)</span>
|
||||||
|
<textarea id="cfg-high-root-files" class="textarea"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>高频扩展名(每行一个)</span>
|
||||||
|
<textarea id="cfg-high-exts" class="textarea"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>低频路径(每行一个)</span>
|
||||||
|
<textarea id="cfg-low-paths" class="textarea"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>高频排除目录(每行一个)</span>
|
||||||
|
<textarea id="cfg-high-excludes" class="textarea"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>低频排除目录(每行一个)</span>
|
||||||
|
<textarea id="cfg-low-excludes" class="textarea"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/tools/box_disaster_sentinel/box_disaster_sentinel.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
*{box-sizing:border-box}
|
||||||
|
html{font-size:14px}
|
||||||
|
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans','Liberation Sans',sans-serif;line-height:1.5;color:#212529;overflow-x:hidden}
|
||||||
|
.container{width:100%;max-width:1140px;margin:0 auto;padding-left:12px;padding-right:12px}
|
||||||
|
.container-fluid{width:100%;padding-left:12px;padding-right:12px}
|
||||||
|
.navbar{display:flex;align-items:center;padding:.5rem 0}
|
||||||
|
.navbar{flex-wrap:wrap}
|
||||||
|
.navbar .container-fluid{display:flex;align-items:center;justify-content:center;gap:.75rem}
|
||||||
|
.bg-primary{background-color:#0d6efd}
|
||||||
|
.bg-dark{background-color:#2b2b2b}
|
||||||
|
.navbar-dark{color:#fff}
|
||||||
|
.navbar-dark .navbar-brand{color:#fff}
|
||||||
|
.navbar .navbar-brand{font-size:1.25rem;font-weight:500}
|
||||||
|
.text-white{color:#fff}
|
||||||
|
.text-muted{color:#6c757d}
|
||||||
|
.small{font-size:.875em}
|
||||||
|
.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;background-color:#f8f9fa;border:1px solid rgba(0,0,0,.125);padding:.375rem .75rem;border-radius:.375rem;transition:background-color .15s ease}
|
||||||
|
.btn:disabled{opacity:.65}
|
||||||
|
.btn:hover{background-color:#e9ecef}
|
||||||
|
.btn-light{color:#212529;background-color:#f8f9fa;border-color:#ced4da}
|
||||||
|
.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}
|
||||||
|
.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}
|
||||||
|
.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
|
||||||
|
.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}
|
||||||
|
.card{display:block;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:.5rem}
|
||||||
|
.card-header{padding:.5rem 1rem;border-bottom:1px solid rgba(0,0,0,.125);background-color:#f8f9fa;font-weight:500}
|
||||||
|
.card-body{padding:1rem}
|
||||||
|
.row{display:flex;flex-wrap:wrap}
|
||||||
|
.row.g-2{gap:.5rem}
|
||||||
|
.col-12{flex:0 0 auto;width:100%}
|
||||||
|
.col-6{flex:0 0 auto;width:50%}
|
||||||
|
@media (min-width:992px){.col-lg-8{flex:0 0 auto;width:66.6667%}.col-lg-4{flex:0 0 auto;width:33.3333%}}
|
||||||
|
.form-label{margin-bottom:.25rem;font-weight:500}
|
||||||
|
.form-control,.form-select{display:block;width:100%;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.375rem}
|
||||||
|
.form-control:focus,.form-select:focus{outline:none;border-color:#86b7fe;box-shadow:0 0 0 .2rem rgba(13,110,253,.25)}
|
||||||
|
.form-control::-webkit-input-placeholder{color:#6c757d}
|
||||||
|
.form-control::-moz-placeholder{color:#6c757d}
|
||||||
|
input[type=datetime-local],input[type=time]{padding-right:2.25rem}
|
||||||
|
.card{box-shadow:0 8px 24px rgba(0,0,0,.08)}
|
||||||
|
.list-group{display:block;border-radius:.5rem;overflow:hidden}
|
||||||
|
.list-group .list-group-item{display:block;padding:.5rem 1rem;background-color:#fff;border-bottom:1px solid rgba(0,0,0,.125)}
|
||||||
|
.list-group-flush .list-group-item{border-left:0;border-right:0}
|
||||||
|
.hstack{display:flex;align-items:center}
|
||||||
|
.justify-content-between{justify-content:space-between}
|
||||||
|
.gap-2{gap:.5rem}
|
||||||
|
.mt-2{margin-top:.5rem}
|
||||||
|
.mb-2{margin-bottom:.5rem}
|
||||||
|
.mb-0{margin-bottom:0}
|
||||||
|
.py-3{padding-top:1rem;padding-bottom:1rem}
|
||||||
|
.d-flex{display:flex}
|
||||||
|
.align-items-center{align-items:center}
|
||||||
|
.d-none{display:none!important}
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #e74c3c;
|
||||||
|
--primary-hover: #c0392b;
|
||||||
|
--secondary-color: #3498db;
|
||||||
|
--success-color: #27ae60;
|
||||||
|
--warning-color: #f39c12;
|
||||||
|
--danger-color: #e74c3c;
|
||||||
|
--light-bg: #f8f9fa;
|
||||||
|
--border-color: #e9ecef;
|
||||||
|
--text-primary: #2c3e50;
|
||||||
|
--text-secondary: #7f8c8d;
|
||||||
|
--text-muted: #95a5a6;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
--border-radius: 8px;
|
||||||
|
--grid-gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)) !important;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-light {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-light:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding-top: 1.5rem !important;
|
||||||
|
padding-bottom: 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日历网格样式 */
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekday {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekday > div {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekday > div:first-child,
|
||||||
|
.calendar-weekday > div:last-child {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
min-height: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.is-today {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: linear-gradient(135deg, #fff8f8, #ffe6e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.is-today .cell-header span {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell .cell-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell .cell-header span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--light-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell .events {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 事件药丸样式优化 */
|
||||||
|
.event-pill {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
|
||||||
|
color: #1565c0;
|
||||||
|
border: 1px solid #90caf9;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin: 0.1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill:hover {
|
||||||
|
background: linear-gradient(135deg, #bbdefb, #90caf9);
|
||||||
|
transform: translateX(2px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill.is-done {
|
||||||
|
background: linear-gradient(135deg, #e8f5e8, #c8e6c9);
|
||||||
|
color: #2e7d32;
|
||||||
|
border-color: #81c784;
|
||||||
|
text-decoration-line: line-through;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片样式 */
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式 */
|
||||||
|
.form-control, .form-select {
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus, .form-select:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(231, 76, 60, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-hover), #a23526);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧事件列表样式优化 */
|
||||||
|
#dayEvents {
|
||||||
|
padding-left: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dayEvents .list-group-item {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dayEvents .list-group-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dayEvents .list-group-item.is-done {
|
||||||
|
background: linear-gradient(135deg, rgba(232, 245, 232, 0.95), rgba(200, 230, 201, 0.95));
|
||||||
|
color: #2e7d32;
|
||||||
|
border-color: #81c784;
|
||||||
|
text-decoration-line: line-through;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 紧凑列表项内容 */
|
||||||
|
.event-list-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#dayEvents .list-group-item {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
#dayEvents .list-group-item {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-time {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-desc {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.calendar-cell {
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.calendar-grid {
|
||||||
|
grid-template-columns: repeat(7, minmax(80px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell .cell-header span {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
grid-template-columns: repeat(7, minmax(70px, 1fr));
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1rem 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
grid-template-columns: repeat(7, minmax(60px, 1fr));
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekday {
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekday > div {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell {
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell .cell-header span {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events {
|
||||||
|
gap: 0.2rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 高密度模式优化 */
|
||||||
|
.high-density .calendar-cell {
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.high-density .event-pill {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
margin: 0.05rem 0;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 事件悬停提示框样式 */
|
||||||
|
.event-tooltip {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #dc3545;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||||
|
max-width: 400px;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
animation: tooltipFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tooltipFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px) scale(0.95); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-header {
|
||||||
|
border-bottom: 2px solid #f8f9fa;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-header strong {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #dc3545;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-table th,
|
||||||
|
.tooltip-table td {
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-table th {
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-table td {
|
||||||
|
color: #495057;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-table tr:last-child th,
|
||||||
|
.tooltip-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.event-tooltip {
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-table th,
|
||||||
|
.tooltip-table td {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-header strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.event-tooltip {
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-table {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-table th {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2px solid #dc3545;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 12px 48px rgba(0,0,0,0.2);
|
||||||
|
z-index: 9999;
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal .modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-bottom: 1px solid #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal .modal-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal .modal-close {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal .modal-close:hover {
|
||||||
|
color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal .modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.event-modal {
|
||||||
|
width: 92vw;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 82vh;
|
||||||
|
}
|
||||||
|
.event-modal .modal-body {
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.calendar-cell .events::-webkit-scrollbar-track {
|
||||||
|
background: var(--light-bg);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell .events::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell .events::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill {
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: -10px 0 0 -10px;
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: transparent;
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>日历提醒系统 · Web</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="css/bootstrap.local.css">
|
||||||
|
<link rel="stylesheet" href="css/styles.css?v=1.1">
|
||||||
|
<script src="js/background.js?v=1.1" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="navbar navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1">日历提醒系统(Web)</span>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button id="btnReload" class="btn btn-light btn-sm">刷新日历</button>
|
||||||
|
<button id="btnPrev" class="btn btn-light btn-sm">上一月</button>
|
||||||
|
<div id="currentMonth" class="text-white"></div>
|
||||||
|
<button id="btnNext" class="btn btn-light btn-sm">下一月</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container py-3">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="calendar-grid" id="calendar-grid" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">当天事件与编辑</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="mb-2">当天事件</h6>
|
||||||
|
<ul id="dayEvents" class="list-group list-group-flush"></ul>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<!-- 新增/编辑事件 表单结构(统一为 WinForms 布局) -->
|
||||||
|
<form id="eventForm" class="vstack gap-2">
|
||||||
|
<input type="hidden" id="eventId">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="title" class="form-label">标题</label>
|
||||||
|
<input type="text" id="title" class="form-control" required maxlength="100" placeholder="如:团队会议">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="content" class="form-label">内容</label>
|
||||||
|
<textarea id="content" class="form-control" rows="4" placeholder="备注说明"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="start" class="form-label">开始时间</label>
|
||||||
|
<input type="datetime-local" id="start" class="form-control" required step="900">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="end" class="form-label">结束时间</label>
|
||||||
|
<input type="datetime-local" id="end" class="form-control" step="900">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check my-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableReminder" checked>
|
||||||
|
<label class="form-check-label" for="enableReminder">启用提醒</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check my-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="completedFlag">
|
||||||
|
<label class="form-check-label" for="completedFlag">已完成,不再提醒</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="reminderRow">
|
||||||
|
<label class="form-label">提醒时间</label>
|
||||||
|
<!-- 不循环:日期+时分 -->
|
||||||
|
<input type="datetime-local" id="remindDateTime" class="form-control" step="900">
|
||||||
|
<!-- 每天/每周:仅时分 -->
|
||||||
|
<input type="time" id="remindTime" class="form-control d-none" step="900">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="recurrenceType" class="form-label">循环类型</label>
|
||||||
|
<select id="recurrenceType" class="form-select">
|
||||||
|
<option value="NONE">不循环</option>
|
||||||
|
<option value="DAILY">每天</option>
|
||||||
|
<option value="WEEKLY">每周</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="recurrenceOptions" class="d-none">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-4">
|
||||||
|
<label for="recurrenceInterval" class="form-label">循环间隔</label>
|
||||||
|
<input type="number" id="recurrenceInterval" class="form-control" value="1" min="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="weekdayGroup" class="mt-2 d-none">
|
||||||
|
<label class="form-label">每周星期几</label>
|
||||||
|
<div class="weekday-list">
|
||||||
|
<label><input type="checkbox" data-weekday="1"> 周一</label>
|
||||||
|
<label><input type="checkbox" data-weekday="2"> 周二</label>
|
||||||
|
<label><input type="checkbox" data-weekday="3"> 周三</label>
|
||||||
|
<label><input type="checkbox" data-weekday="4"> 周四</label>
|
||||||
|
<label><input type="checkbox" data-weekday="5"> 周五</label>
|
||||||
|
<label><input type="checkbox" data-weekday="6"> 周六</label>
|
||||||
|
<label><input type="checkbox" data-weekday="7"> 周日</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hstack gap-2 mt-2">
|
||||||
|
<button type="submit" class="btn btn-primary">保存</button>
|
||||||
|
<button type="button" id="btnReset" class="btn btn-secondary">重置</button>
|
||||||
|
<button type="button" id="btnDelete" class="btn btn-danger d-none">删除</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="container py-3 text-muted small">
|
||||||
|
<div>模式:<span id="modeLabel"></span> · 提醒依赖浏览器通知(需授权)</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="/assets/auth.js"></script>
|
||||||
|
<script src="js/db.js"></script>
|
||||||
|
<script src="js/models.js?v=1.1"></script>
|
||||||
|
<script src="js/sync.js?v=1.1"></script>
|
||||||
|
<script src="js/ui.js?v=1.3"></script>
|
||||||
|
<script src="js/main.js?v=1.3"></script>
|
||||||
|
<script src="/lib/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
const BackgroundManager = (function () {
|
||||||
|
const CACHE_KEY = 'background_cache';
|
||||||
|
const LAST_UPDATE_KEY = 'background_last_update';
|
||||||
|
const CACHE_EXPIRE_DAYS = 30;
|
||||||
|
const CONFIG_ENDPOINT = '/api/calendar/sys-config';
|
||||||
|
let configCache = null;
|
||||||
|
|
||||||
|
const UNSPLASH_ACCESS_KEY = 'osWW4Fx0IbpfJs2LbCl_Jpv5ej-6ZaphyIX9sgdPdxI';
|
||||||
|
const PICSUM_BASE_URL = 'https://picsum.photos';
|
||||||
|
const CHINESE_THEME_KEYWORDS = [
|
||||||
|
'chinese landscape', 'chinese painting', 'bamboo', 'lotus', 'plum blossom',
|
||||||
|
'orchid', 'chrysanthemum', 'mountain water', 'ink wash', 'traditional chinese',
|
||||||
|
'landscape painting', 'watercolor', 'minimalist', 'serene', 'nature'
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
if (configCache) return configCache;
|
||||||
|
try {
|
||||||
|
const res = await fetch(CONFIG_ENDPOINT, { cache: 'no-store' });
|
||||||
|
if (!res.ok) {
|
||||||
|
configCache = {};
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const cfg = data && data.config && typeof data.config === 'object' ? data.config : {};
|
||||||
|
configCache = cfg;
|
||||||
|
return cfg;
|
||||||
|
} catch (_) {
|
||||||
|
configCache = {};
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLocalPoolBackground(cfg) {
|
||||||
|
try {
|
||||||
|
const theme = String((cfg && (cfg.background_theme || cfg.backgroundTheme)) || '默认背景');
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('theme', theme);
|
||||||
|
params.set('prefer_local', '1');
|
||||||
|
const res = await fetch('/api/home/bg?' + params.toString(), { cache: 'no-store' });
|
||||||
|
if (!res.ok) return '';
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const url = data && typeof data.url === 'string' ? data.url : '';
|
||||||
|
return url || '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllPoolBackground() {
|
||||||
|
const themes = [
|
||||||
|
'夜空星轨',
|
||||||
|
'蓝天白云',
|
||||||
|
'治愈森林',
|
||||||
|
'运动风',
|
||||||
|
'科技霓虹',
|
||||||
|
'商务简约',
|
||||||
|
'暖色日落',
|
||||||
|
'冷色极夜'
|
||||||
|
];
|
||||||
|
const theme = themes[Math.floor(Math.random() * themes.length)];
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('theme', theme);
|
||||||
|
params.set('prefer_local', '1');
|
||||||
|
const res = await fetch('/api/home/bg?' + params.toString(), { cache: 'no-store' });
|
||||||
|
if (!res.ok) return '';
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const url = data && typeof data.url === 'string' ? data.url : '';
|
||||||
|
return url || '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDailyBackground() {
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
const cached = getCachedBackground();
|
||||||
|
|
||||||
|
if (cached && cached.date === today && cached.url) {
|
||||||
|
return cached.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableDynamic = cfg && Object.prototype.hasOwnProperty.call(cfg, 'ENABLE_DYNAMIC_BACKGROUND')
|
||||||
|
? cfg.ENABLE_DYNAMIC_BACKGROUND !== false
|
||||||
|
: true;
|
||||||
|
const modeRaw = cfg && cfg.background_mode ? String(cfg.background_mode) : '';
|
||||||
|
const mode = modeRaw.toUpperCase();
|
||||||
|
const useAllPool = mode === 'ALL_POOL';
|
||||||
|
|
||||||
|
if (!enableDynamic) {
|
||||||
|
const url = getDefaultBackground();
|
||||||
|
cacheBackground(url, today);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useAllPool) {
|
||||||
|
const allUrl = await getAllPoolBackground();
|
||||||
|
if (allUrl) {
|
||||||
|
cacheBackground(allUrl, today);
|
||||||
|
return allUrl;
|
||||||
|
}
|
||||||
|
const fallback = getDefaultBackground();
|
||||||
|
cacheBackground(fallback, today);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg && (cfg.background_theme || cfg.backgroundTheme)) {
|
||||||
|
const localUrl = await getLocalPoolBackground(cfg);
|
||||||
|
if (localUrl) {
|
||||||
|
cacheBackground(localUrl, today);
|
||||||
|
return localUrl;
|
||||||
|
}
|
||||||
|
const fallback = getDefaultBackground();
|
||||||
|
cacheBackground(fallback, today);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unsplashUrl = await tryUnsplash();
|
||||||
|
if (unsplashUrl) {
|
||||||
|
cacheBackground(unsplashUrl, today);
|
||||||
|
return unsplashUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unsplash API failed:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached && cached.url) return cached.url;
|
||||||
|
try {
|
||||||
|
const picsumUrl = await tryPicsum();
|
||||||
|
if (picsumUrl) {
|
||||||
|
cacheBackground(picsumUrl, today);
|
||||||
|
return picsumUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Picsum API failed:', error);
|
||||||
|
}
|
||||||
|
const fallback = getDefaultBackground();
|
||||||
|
cacheBackground(fallback, today);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryUnsplash() {
|
||||||
|
if (!UNSPLASH_ACCESS_KEY || UNSPLASH_ACCESS_KEY === 'your_unsplash_access_key_here') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomKeyword = CHINESE_THEME_KEYWORDS[Math.floor(Math.random() * CHINESE_THEME_KEYWORDS.length)];
|
||||||
|
const url = `https://api.unsplash.com/photos/random?query=${encodeURIComponent(randomKeyword)}&orientation=landscape&client_id=${UNSPLASH_ACCESS_KEY}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Accept-Version': 'v1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Unsplash API error:', response.status, response.statusText);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.urls.regular;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unsplash fetch failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryPicsum() {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const imageId = Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
|
return `${PICSUM_BASE_URL}/${width}/${height}?blur=2&image=${imageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultBackground() {
|
||||||
|
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedBackground() {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
|
const lastUpdate = localStorage.getItem(LAST_UPDATE_KEY);
|
||||||
|
|
||||||
|
if (!cached || !lastUpdate) return null;
|
||||||
|
|
||||||
|
// 检查缓存是否过期(30天)
|
||||||
|
const updateDate = new Date(lastUpdate);
|
||||||
|
const expireDate = new Date(updateDate);
|
||||||
|
expireDate.setDate(expireDate.getDate() + CACHE_EXPIRE_DAYS);
|
||||||
|
|
||||||
|
if (new Date() > expireDate) {
|
||||||
|
localStorage.removeItem(CACHE_KEY);
|
||||||
|
localStorage.removeItem(LAST_UPDATE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(cached);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read cache:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheBackground(url, date) {
|
||||||
|
try {
|
||||||
|
const cacheData = { url, date };
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
|
||||||
|
localStorage.setItem(LAST_UPDATE_KEY, new Date().toISOString());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to cache background:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBackground(url) {
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
let bgContainer = document.getElementById('background-container');
|
||||||
|
if (!bgContainer) {
|
||||||
|
bgContainer = document.createElement('div');
|
||||||
|
bgContainer.id = 'background-container';
|
||||||
|
const isImage = typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'));
|
||||||
|
bgContainer.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
background: ${isImage ? `url(${url}) center/cover no-repeat` : url};
|
||||||
|
opacity: 0.15;
|
||||||
|
filter: blur(8px);
|
||||||
|
`;
|
||||||
|
document.body.appendChild(bgContainer);
|
||||||
|
} else {
|
||||||
|
const isImage = typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'));
|
||||||
|
bgContainer.style.background = isImage ? `url(${url}) center/cover no-repeat` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.style.background = 'transparent';
|
||||||
|
const importantElements = document.querySelectorAll('.card, .calendar-grid, .navbar');
|
||||||
|
importantElements.forEach(el => {
|
||||||
|
el.style.backgroundColor = 'rgba(255, 255, 255, 0.95)';
|
||||||
|
el.style.backdropFilter = 'blur(10px)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const backgroundUrl = await getDailyBackground();
|
||||||
|
applyBackground(backgroundUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set background:', error);
|
||||||
|
applyBackground(getDefaultBackground());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
refresh: init
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 页面加载时初始化
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
BackgroundManager.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导出供其他模块使用
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = BackgroundManager;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const AppConfig = {
|
||||||
|
mode: 'shared', // 'standalone' | 'shared'
|
||||||
|
API_BASE_URL: '/api/calendar/events',
|
||||||
|
ENABLE_SYNC: true, // 共享模式下开启
|
||||||
|
SYNC_INTERVAL_MS: 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.AppConfig = AppConfig;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// IndexedDB 存储封装
|
||||||
|
const DB = (function () {
|
||||||
|
const DB_NAME = 'calendar_reminder';
|
||||||
|
const DB_VERSION = 2; // 升级版本以创建 outbox 队列
|
||||||
|
const STORE_EVENTS = 'events';
|
||||||
|
const STORE_OUTBOX = 'outbox';
|
||||||
|
|
||||||
|
let _db;
|
||||||
|
|
||||||
|
function openDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
req.onupgradeneeded = (ev) => {
|
||||||
|
const db = ev.target.result;
|
||||||
|
// 事件存储
|
||||||
|
if (!db.objectStoreNames.contains(STORE_EVENTS)) {
|
||||||
|
const store = db.createObjectStore(STORE_EVENTS, { keyPath: 'id' });
|
||||||
|
store.createIndex('by_start', 'start');
|
||||||
|
store.createIndex('by_updated', 'updatedAt');
|
||||||
|
}
|
||||||
|
// 离线 outbox 队列
|
||||||
|
if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
|
||||||
|
db.createObjectStore(STORE_OUTBOX, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => {
|
||||||
|
_db = req.result;
|
||||||
|
resolve(_db);
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tx(storeName, mode = 'readonly') {
|
||||||
|
const t = _db.transaction(storeName, mode);
|
||||||
|
return t.objectStore(storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDB() {
|
||||||
|
if (_db) return _db;
|
||||||
|
return await openDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvent(ev) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = tx(STORE_EVENTS, 'readwrite');
|
||||||
|
const req = store.add(ev);
|
||||||
|
req.onsuccess = () => resolve(ev);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEvent(ev) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = tx(STORE_EVENTS, 'readwrite');
|
||||||
|
const req = store.put(ev);
|
||||||
|
req.onsuccess = () => resolve(ev);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEvent(id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = tx(STORE_EVENTS, 'readwrite');
|
||||||
|
const req = store.delete(id);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEvent(id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = tx(STORE_EVENTS, 'readonly');
|
||||||
|
const req = store.get(id);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listEventsByMonth(year, month) {
|
||||||
|
// month: 1..12
|
||||||
|
const start = new Date(Date.UTC(year, month - 1, 1, 0, 0, 0));
|
||||||
|
const end = new Date(Date.UTC(year, month, 0, 23, 59, 59));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = tx(STORE_EVENTS, 'readonly');
|
||||||
|
const idx = store.index('by_start');
|
||||||
|
const range = IDBKeyRange.bound(start.toISOString(), end.toISOString());
|
||||||
|
const result = [];
|
||||||
|
const req = idx.openCursor(range);
|
||||||
|
req.onsuccess = (e) => {
|
||||||
|
const cursor = e.target.result;
|
||||||
|
if (cursor) {
|
||||||
|
result.push(cursor.value);
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Outbox 队列 ---
|
||||||
|
function genId() { return Date.now() + '-' + Math.random().toString(36).slice(2); }
|
||||||
|
|
||||||
|
function addOutbox(entry) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = tx(STORE_OUTBOX, 'readwrite');
|
||||||
|
const req = store.add(entry);
|
||||||
|
req.onsuccess = () => resolve(entry);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOutboxUpsert(ev) {
|
||||||
|
const entry = { id: genId(), type: 'upsert', payload: ev, timestamp: new Date().toISOString() };
|
||||||
|
return addOutbox(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOutboxDelete(id) {
|
||||||
|
const entry = { id: genId(), type: 'delete', payload: { id }, timestamp: new Date().toISOString() };
|
||||||
|
return addOutbox(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listOutbox() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = tx(STORE_OUTBOX, 'readonly');
|
||||||
|
const result = [];
|
||||||
|
const req = store.openCursor();
|
||||||
|
req.onsuccess = (e) => {
|
||||||
|
const cursor = e.target.result;
|
||||||
|
if (cursor) { result.push(cursor.value); cursor.continue(); }
|
||||||
|
else { resolve(result); }
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOutbox(id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = tx(STORE_OUTBOX, 'readwrite');
|
||||||
|
const req = store.delete(id);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initDB,
|
||||||
|
addEvent,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
getEvent,
|
||||||
|
listEventsByMonth,
|
||||||
|
// outbox
|
||||||
|
addOutboxUpsert,
|
||||||
|
addOutboxDelete,
|
||||||
|
listOutbox,
|
||||||
|
removeOutbox,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.DB = DB;
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
// 主入口:初始化、交互、提醒与 PWA
|
||||||
|
(function () {
|
||||||
|
let current = new Date();
|
||||||
|
let monthEvents = [];
|
||||||
|
let monthOccurrences = [];
|
||||||
|
const monthCache = new Map();
|
||||||
|
// 进程内临时集:已提醒过的事件键,避免重复提醒(不落地)
|
||||||
|
const notifiedKeys = new Set();
|
||||||
|
const scheduledKeys = new Set();
|
||||||
|
let heartbeatTimer = null;
|
||||||
|
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟容错窗口
|
||||||
|
let autoRefreshTimer = null;
|
||||||
|
let lastUserActionTs = Date.now();
|
||||||
|
const AUTORELOAD_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
document.getElementById('modeLabel').textContent = AppConfig.mode;
|
||||||
|
|
||||||
|
// 通知权限请求(可延迟到首次需要时)
|
||||||
|
if ('Notification' in window) {
|
||||||
|
try {
|
||||||
|
if (Notification.permission === 'default') {
|
||||||
|
await Notification.requestPermission();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用 Service Worker,确保不使用任何本地缓存
|
||||||
|
await unregisterServiceWorker();
|
||||||
|
|
||||||
|
bindNav();
|
||||||
|
bindReload();
|
||||||
|
bindForm();
|
||||||
|
bindAutoRefresh();
|
||||||
|
|
||||||
|
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||||
|
startReminderHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindNav() {
|
||||||
|
document.getElementById('btnPrev').addEventListener('click', async () => {
|
||||||
|
current.setMonth(current.getMonth() - 1);
|
||||||
|
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||||
|
});
|
||||||
|
document.getElementById('btnNext').addEventListener('click', async () => {
|
||||||
|
current.setMonth(current.getMonth() + 1);
|
||||||
|
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindReload() {
|
||||||
|
const btn = document.getElementById('btnReload');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
lastUserActionTs = Date.now();
|
||||||
|
try {
|
||||||
|
UI.showGlobalModal('正在刷新日历...');
|
||||||
|
try { UI.appendGlobalModalLog('清空内存缓存'); } catch (_) {}
|
||||||
|
monthCache.clear();
|
||||||
|
notifiedKeys.clear();
|
||||||
|
scheduledKeys.clear();
|
||||||
|
try { UI.appendGlobalModalLog('注销 Service Worker'); } catch (_) {}
|
||||||
|
await unregisterServiceWorker();
|
||||||
|
if ('caches' in window) {
|
||||||
|
try {
|
||||||
|
UI.appendGlobalModalLog('清除浏览器缓存存储');
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map(k => caches.delete(k)));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||||
|
try { UI.appendGlobalModalLog('刷新流程完成'); } catch (_) {}
|
||||||
|
try { UI.completeGlobalModal('刷新完成'); } catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMonth(year, month) {
|
||||||
|
UI.showGlobalModal('正在查询当月数据...');
|
||||||
|
try { UI.appendGlobalModalLog('开始查询 ' + year + '-' + month + ' 数据'); } catch (_) {}
|
||||||
|
let ok = true;
|
||||||
|
console.time('loadMonthTotal');
|
||||||
|
try {
|
||||||
|
const key = `${year}-${month}`;
|
||||||
|
const cached = monthCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
monthEvents = cached;
|
||||||
|
} else {
|
||||||
|
console.time('fetchAPI');
|
||||||
|
const remote = await SyncAPI.listByMonth(year, month);
|
||||||
|
console.timeEnd('fetchAPI');
|
||||||
|
console.log('API returned events:', remote ? remote.length : 0);
|
||||||
|
monthEvents = remote;
|
||||||
|
monthCache.set(key, remote);
|
||||||
|
}
|
||||||
|
const prevY = month === 1 ? year - 1 : year;
|
||||||
|
const prevM = month === 1 ? 12 : month - 1;
|
||||||
|
const prevKey = `${prevY}-${prevM}`;
|
||||||
|
const prevCached = monthCache.get(prevKey);
|
||||||
|
let prevEvents = [];
|
||||||
|
if (prevCached) {
|
||||||
|
prevEvents = prevCached;
|
||||||
|
} else {
|
||||||
|
const prevRemote = await SyncAPI.listByMonth(prevY, prevM);
|
||||||
|
prevEvents = prevRemote;
|
||||||
|
monthCache.set(prevKey, prevRemote);
|
||||||
|
}
|
||||||
|
const merged = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const pushUnique = (list) => {
|
||||||
|
list.forEach(e => {
|
||||||
|
if (!e) return;
|
||||||
|
const k = e.id ? `id:${e.id}` : `k:${e.title || ''}|${e.start || ''}|${e.end || ''}`;
|
||||||
|
if (seen.has(k)) return;
|
||||||
|
seen.add(k);
|
||||||
|
merged.push(e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
pushUnique(monthEvents);
|
||||||
|
pushUnique(prevEvents);
|
||||||
|
monthEvents = merged;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('远程拉取失败', err);
|
||||||
|
try { UI.appendGlobalModalLog('错误:远程拉取失败 ' + (err && err.message ? err.message : '')); } catch (_) {}
|
||||||
|
monthEvents = [];
|
||||||
|
alert('远程拉取失败,请稍后重试');
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
try { UI.appendGlobalModalLog('开始展开当月循环事件'); } catch (_) {}
|
||||||
|
console.time('expandEvents');
|
||||||
|
monthOccurrences = expandEventsForMonth(monthEvents, year, month);
|
||||||
|
console.timeEnd('expandEvents');
|
||||||
|
console.log('Expanded occurrences:', monthOccurrences.length);
|
||||||
|
try { UI.appendGlobalModalLog('展开完成,实例数=' + monthOccurrences.length); } catch (_) {}
|
||||||
|
|
||||||
|
try { UI.appendGlobalModalLog('开始渲染日历'); } catch (_) {}
|
||||||
|
console.time('renderCalendar');
|
||||||
|
// 使用 requestAnimationFrame 给 UI 线程喘息机会,避免卡死
|
||||||
|
await new Promise(r => requestAnimationFrame(() => {
|
||||||
|
UI.renderCalendar(year, month, monthOccurrences);
|
||||||
|
r();
|
||||||
|
}));
|
||||||
|
console.timeEnd('renderCalendar');
|
||||||
|
try { UI.appendGlobalModalLog('渲染完成'); } catch (_) {}
|
||||||
|
|
||||||
|
// 立即补偿提醒(捕捉刚到点或即将到点的事件)
|
||||||
|
catchUpNotifications(monthOccurrences);
|
||||||
|
// 设置未来提醒的定时器
|
||||||
|
scheduleNotifications(monthOccurrences);
|
||||||
|
// 刷新后重启心跳,确保新数据生效
|
||||||
|
startReminderHeartbeat();
|
||||||
|
prefetchAdjacentMonths(year, month);
|
||||||
|
if (ok) {
|
||||||
|
try { UI.appendGlobalModalLog('查询完成'); } catch (_) {}
|
||||||
|
try { UI.completeGlobalModal('查询完成'); } catch (_) {}
|
||||||
|
} else {
|
||||||
|
try { UI.appendGlobalModalLog('查询失败'); } catch (_) {}
|
||||||
|
try { UI.updateGlobalModalMessage('查询失败'); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶层自执行函数内新增:切换提醒输入与构造循环/提醒
|
||||||
|
function updateReminderInputs() {
|
||||||
|
const type = document.getElementById('recurrenceType').value;
|
||||||
|
const enabled = document.getElementById('enableReminder').checked;
|
||||||
|
const dtWrap = document.getElementById('remindDateTime');
|
||||||
|
const tWrap = document.getElementById('remindTime');
|
||||||
|
const opts = document.getElementById('recurrenceOptions');
|
||||||
|
const wgroup = document.getElementById('weekdayGroup');
|
||||||
|
|
||||||
|
dtWrap.classList.toggle('d-none', !enabled || (type === 'DAILY' || type === 'WEEKLY'));
|
||||||
|
tWrap.classList.toggle('d-none', !enabled || (type === 'NONE'));
|
||||||
|
opts.classList.toggle('d-none', type === 'NONE');
|
||||||
|
wgroup.classList.toggle('d-none', type !== 'WEEKLY');
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeRecurrence() {
|
||||||
|
const type = document.getElementById('recurrenceType').value;
|
||||||
|
if (type === 'NONE') return '';
|
||||||
|
const interval = Math.max(1, parseInt(document.getElementById('recurrenceInterval').value || '1', 10));
|
||||||
|
if (type === 'DAILY') return `DAILY;interval=${interval}`;
|
||||||
|
const checked = Array.from(document.querySelectorAll('#weekdayGroup [data-weekday]'))
|
||||||
|
.filter(el => el.checked).map(el => parseInt(el.getAttribute('data-weekday'), 10)).sort((a, b) => a - b);
|
||||||
|
const by = checked.length ? `;by=${checked.join(',')}` : '';
|
||||||
|
return `WEEKLY;interval=${interval}${by}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineDateAndTime(startISO, timeStr) {
|
||||||
|
const base = new Date(startISO);
|
||||||
|
const [hh, mmRaw] = (timeStr || '00:00').split(':').map(s => parseInt(s, 10));
|
||||||
|
const q = Math.round((mmRaw || 0) / 15) * 15;
|
||||||
|
const mm = Math.min(45, Math.max(0, q));
|
||||||
|
base.setHours(hh || 0, mm, 0, 0);
|
||||||
|
return base.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可视兜底(Toast):即使系统静默通知也显示
|
||||||
|
function ensureToastContainer() {
|
||||||
|
let c = document.getElementById('toastContainer');
|
||||||
|
if (!c) {
|
||||||
|
c = document.createElement('div');
|
||||||
|
c.id = 'toastContainer';
|
||||||
|
c.className = 'toast-container';
|
||||||
|
document.body.appendChild(c);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
function showToast(title, body) {
|
||||||
|
try {
|
||||||
|
const c = ensureToastContainer();
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'inapp-toast';
|
||||||
|
const strong = document.createElement('strong'); strong.textContent = title || '提醒';
|
||||||
|
const p = document.createElement('div'); p.textContent = body || '';
|
||||||
|
item.appendChild(strong); item.appendChild(p); c.appendChild(item);
|
||||||
|
setTimeout(() => { try { item.classList.add('fadeout'); } catch (_) {}
|
||||||
|
setTimeout(() => { try { c.removeChild(item); } catch (_) {} }, 300);
|
||||||
|
}, 5000);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
function triggerNotify(e) {
|
||||||
|
const title = '提醒:' + (e.title || '');
|
||||||
|
const body = `${new Date(e.start).toLocaleString()} - ${new Date(e.end).toLocaleString()}`;
|
||||||
|
if ('Notification' in window && Notification.permission === 'granted') {
|
||||||
|
try { new Notification(title, { body }); } catch (_) {}
|
||||||
|
}
|
||||||
|
showToast(title, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindForm() {
|
||||||
|
const form = document.getElementById('eventForm');
|
||||||
|
const btnReset = document.getElementById('btnReset');
|
||||||
|
const btnDelete = document.getElementById('btnDelete');
|
||||||
|
const btnSubmit = form.querySelector('[type="submit"]');
|
||||||
|
|
||||||
|
// 新增:循环与提醒输入联动
|
||||||
|
document.getElementById('recurrenceType').addEventListener('change', updateReminderInputs);
|
||||||
|
document.getElementById('enableReminder').addEventListener('change', updateReminderInputs);
|
||||||
|
updateReminderInputs();
|
||||||
|
const inputsToSnap = ['start','end','remindDateTime','remindTime'];
|
||||||
|
inputsToSnap.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
['change','blur'].forEach(evt => {
|
||||||
|
el.addEventListener(evt, () => {
|
||||||
|
try {
|
||||||
|
if (id === 'remindTime') {
|
||||||
|
const v = el.value || '';
|
||||||
|
const parts = v.split(':');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const h = parseInt(parts[0] || '0', 10);
|
||||||
|
const mRaw = parseInt(parts[1] || '0', 10);
|
||||||
|
const m = Math.min(45, Math.max(0, Math.round(mRaw / 15) * 15));
|
||||||
|
el.value = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
} else if (el.value) {
|
||||||
|
const d = new Date(el.value);
|
||||||
|
const q = Math.round(d.getMinutes() / 15) * 15;
|
||||||
|
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
el.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
// 启用原生表单校验(required 等)
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存按钮进入加载态
|
||||||
|
btnSubmit.disabled = true;
|
||||||
|
const originalText = btnSubmit.textContent;
|
||||||
|
btnSubmit.textContent = '保存中...';
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
UI.showGlobalModal('正在保存...');
|
||||||
|
try { UI.appendGlobalModalLog('收集表单数据'); } catch (_) {}
|
||||||
|
data = collectForm();
|
||||||
|
if (!data.title || !data.start) {
|
||||||
|
try { UI.appendGlobalModalLog('错误:缺少标题或开始时间'); } catch (_) {}
|
||||||
|
alert('请填写标题与开始时间');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.id) {
|
||||||
|
const base = (form.eventLoaded && form.eventLoaded.id === data.id) ? form.eventLoaded : {};
|
||||||
|
const updated = Models.normalizeForUpdate({ ...base, ...data });
|
||||||
|
try { UI.appendGlobalModalLog('准备更新事件'); } catch (_) {}
|
||||||
|
await SyncAPI.upsertEvent(updated);
|
||||||
|
} else {
|
||||||
|
const evNew = Models.createEvent(data);
|
||||||
|
try { UI.appendGlobalModalLog('准备创建新事件'); } catch (_) {}
|
||||||
|
await SyncAPI.upsertEvent(evNew);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新当前月
|
||||||
|
try { UI.appendGlobalModalLog('刷新当月视图'); } catch (_) {}
|
||||||
|
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||||
|
resetForm();
|
||||||
|
try { UI.appendGlobalModalLog('保存成功'); } catch (_) {}
|
||||||
|
try { UI.completeGlobalModal('保存成功'); } catch (_) {}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存失败', err);
|
||||||
|
alert('保存失败:' + (err && err.message ? err.message : err));
|
||||||
|
try { UI.appendGlobalModalLog('错误:保存失败 ' + (err && err.message ? err.message : '')); } catch (_) {}
|
||||||
|
try { UI.updateGlobalModalMessage('保存失败'); } catch (_) {}
|
||||||
|
} finally {
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
btnSubmit.textContent = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnReset.addEventListener('click', resetForm);
|
||||||
|
|
||||||
|
btnDelete.addEventListener('click', async () => {
|
||||||
|
const id = document.getElementById('eventId').value;
|
||||||
|
if (!id) return;
|
||||||
|
if (!confirm('确认删除该事件吗?')) return;
|
||||||
|
try {
|
||||||
|
UI.showGlobalModal('正在删除...');
|
||||||
|
try { UI.appendGlobalModalLog('准备删除事件 ' + id); } catch (_) {}
|
||||||
|
try { UI.appendGlobalModalLog('调用 deleteEvent'); } catch (_) {}
|
||||||
|
await SyncAPI.deleteEvent(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('远端删除失败', e);
|
||||||
|
alert('删除失败:' + (e && e.message ? e.message : e));
|
||||||
|
try { UI.appendGlobalModalLog('错误:删除失败 ' + (e && e.message ? e.message : '')); } catch (_) {}
|
||||||
|
try { UI.updateGlobalModalMessage('删除失败'); } catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { UI.appendGlobalModalLog('删除成功,刷新当月视图'); } catch (_) {}
|
||||||
|
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||||
|
resetForm();
|
||||||
|
try { UI.appendGlobalModalLog('删除完成'); } catch (_) {}
|
||||||
|
try { UI.completeGlobalModal('删除完成'); } catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectForm() {
|
||||||
|
const id = document.getElementById('eventId').value || '';
|
||||||
|
const title = document.getElementById('title').value || '';
|
||||||
|
const content = document.getElementById('content').value || '';
|
||||||
|
const completed = document.getElementById('completedFlag').checked;
|
||||||
|
const startInput = document.getElementById('start').value;
|
||||||
|
const start = startInput ? new Date(startInput) : null;
|
||||||
|
const endInput = document.getElementById('end').value;
|
||||||
|
const end = endInput ? new Date(endInput) : null;
|
||||||
|
|
||||||
|
const type = document.getElementById('recurrenceType').value;
|
||||||
|
const enableReminder = document.getElementById('enableReminder').checked;
|
||||||
|
const remindDateTime = document.getElementById('remindDateTime').value;
|
||||||
|
const remindTime = document.getElementById('remindTime').value;
|
||||||
|
|
||||||
|
const recurrence = composeRecurrence();
|
||||||
|
|
||||||
|
function roundToQuarter(d) {
|
||||||
|
if (!d) return d;
|
||||||
|
const q = Math.round(d.getMinutes() / 15) * 15;
|
||||||
|
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
if (start) roundToQuarter(start);
|
||||||
|
if (end) roundToQuarter(end);
|
||||||
|
let remindAt = '';
|
||||||
|
if (enableReminder) {
|
||||||
|
if (type === 'NONE') {
|
||||||
|
remindAt = remindDateTime ? roundToQuarter(new Date(remindDateTime)).toISOString() : '';
|
||||||
|
} else {
|
||||||
|
remindAt = (start && remindTime) ? combineDateAndTime(start.toISOString(), remindTime) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
completed,
|
||||||
|
allDay: false,
|
||||||
|
start: start ? start.toISOString() : '',
|
||||||
|
end: end ? end.toISOString() : '',
|
||||||
|
remindAt,
|
||||||
|
recurrence
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
const form = document.getElementById('eventForm');
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('eventId').value = '';
|
||||||
|
document.getElementById('btnDelete').classList.add('d-none');
|
||||||
|
document.getElementById('dayEvents').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNotifications(events) {
|
||||||
|
const now = Date.now();
|
||||||
|
const next24h = now + 24 * 3600_000;
|
||||||
|
events.forEach(e => {
|
||||||
|
if (e.completed) return;
|
||||||
|
const upcoming = computeUpcomingReminders(e, now, next24h);
|
||||||
|
upcoming.forEach(remindIso => {
|
||||||
|
const ts = new Date(remindIso).getTime();
|
||||||
|
const delay = ts - now;
|
||||||
|
if (delay > 0) {
|
||||||
|
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
|
||||||
|
if (notifiedKeys.has(key) || scheduledKeys.has(key)) return;
|
||||||
|
scheduledKeys.add(key);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (notifiedKeys.has(key)) return;
|
||||||
|
triggerNotify({ ...e, remindAt: remindIso });
|
||||||
|
notifiedKeys.add(key);
|
||||||
|
scheduledKeys.delete(key);
|
||||||
|
} catch (_) {}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即补偿:加载数据后,立刻检查 2 分钟窗口内的到期提醒
|
||||||
|
function catchUpNotifications(events) {
|
||||||
|
const now = Date.now();
|
||||||
|
const since = now - WINDOW_MS;
|
||||||
|
try {
|
||||||
|
events.forEach(e => {
|
||||||
|
if (e.completed) return;
|
||||||
|
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||||
|
windowReminders.forEach(remindIso => {
|
||||||
|
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
|
||||||
|
if (!notifiedKeys.has(key)) {
|
||||||
|
triggerNotify({ ...e, remindAt: remindIso });
|
||||||
|
notifiedKeys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReminderHeartbeat() {
|
||||||
|
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
||||||
|
// 每分钟检查一次,捕捉睡眠/时间漂移导致的错过提醒
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const since = now - WINDOW_MS;
|
||||||
|
try {
|
||||||
|
monthOccurrences.forEach(e => {
|
||||||
|
if (e.completed) return;
|
||||||
|
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||||
|
windowReminders.forEach(remindIso => {
|
||||||
|
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
|
||||||
|
if (!notifiedKeys.has(key)) {
|
||||||
|
triggerNotify({ ...e, remindAt: remindIso });
|
||||||
|
notifiedKeys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindAutoRefresh() {
|
||||||
|
['click','keydown','mousemove','touchstart','wheel'].forEach(ev => {
|
||||||
|
document.addEventListener(ev, () => { lastUserActionTs = Date.now(); }, { passive: true });
|
||||||
|
});
|
||||||
|
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
|
||||||
|
autoRefreshTimer = setInterval(() => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
const idleMs = Date.now() - lastUserActionTs;
|
||||||
|
if (idleMs >= AUTORELOAD_MS) {
|
||||||
|
const y = current.getFullYear();
|
||||||
|
const m = current.getMonth() + 1;
|
||||||
|
loadMonth(y, m);
|
||||||
|
}
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unregisterServiceWorker() {
|
||||||
|
try {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations();
|
||||||
|
for (const r of regs) { try { await r.unregister(); } catch (_) {} }
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已停用后台同步与本地缓存
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', init);
|
||||||
|
// 不再开启后台同步
|
||||||
|
})();
|
||||||
|
function parseRecurrenceStr(str) {
|
||||||
|
const r = (str || '').trim();
|
||||||
|
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||||
|
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||||
|
const mInt = /interval=(\d+)/.exec(r);
|
||||||
|
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||||
|
const mBy = /by=([0-9,]+)/.exec(r);
|
||||||
|
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n>=1 && n<=7) : [];
|
||||||
|
return { type, interval, by };
|
||||||
|
}
|
||||||
|
function setTimeFromIso(dateObj, timeSourceIso) {
|
||||||
|
const d = new Date(dateObj.getTime());
|
||||||
|
const t = timeSourceIso ? new Date(timeSourceIso) : null;
|
||||||
|
const hh = t ? t.getHours() : 0;
|
||||||
|
const mm = t ? t.getMinutes() : 0;
|
||||||
|
d.setHours(hh, mm, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
function expandEventsForMonth(events, year, month) {
|
||||||
|
const start = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const end = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999));
|
||||||
|
const out = [];
|
||||||
|
events.forEach(e => {
|
||||||
|
if (!e || e.deleted) return;
|
||||||
|
const rec = parseRecurrenceStr(e.recurrence);
|
||||||
|
const evStart = new Date(e.start);
|
||||||
|
const evEnd = e.end ? new Date(e.end) : new Date(evStart.getTime() + 60*60*1000);
|
||||||
|
if (rec.type === 'NONE') {
|
||||||
|
out.push(e);
|
||||||
|
} else if (rec.type === 'DAILY') {
|
||||||
|
const from = new Date(Math.max(start.getTime(), Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate())));
|
||||||
|
const to = new Date(Math.min(end.getTime(), Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate())));
|
||||||
|
let cursor = new Date(from.getTime());
|
||||||
|
while (cursor <= to) {
|
||||||
|
const diffDays = Math.floor((cursor.getTime() - Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))/(24*3600*1000));
|
||||||
|
if (diffDays % rec.interval === 0) {
|
||||||
|
const occStartIso = setTimeFromIso(cursor, e.start);
|
||||||
|
const occRemindIso = setTimeFromIso(cursor, e.remindAt || e.start);
|
||||||
|
out.push({ ...e, start: occStartIso, remindAt: occRemindIso });
|
||||||
|
}
|
||||||
|
cursor = new Date(cursor.getTime() + 24*3600*1000);
|
||||||
|
|
||||||
|
// Safety break
|
||||||
|
if (out.length > 5000) {
|
||||||
|
console.warn('Excessive recurrence detected, truncating.', e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (rec.type === 'WEEKLY') {
|
||||||
|
const from = new Date(Math.max(start.getTime(), Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate())));
|
||||||
|
const to = new Date(Math.min(end.getTime(), Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate())));
|
||||||
|
const by = rec.by.length ? rec.by : [((new Date(e.start)).getDay() || 7)];
|
||||||
|
let cursor = new Date(from.getTime());
|
||||||
|
while (cursor <= to) {
|
||||||
|
const weekday = cursor.getUTCDay() === 0 ? 7 : cursor.getUTCDay();
|
||||||
|
const weeksSinceStart = Math.floor((cursor.getTime() - Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))/(7*24*3600*1000));
|
||||||
|
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||||
|
const occStartIso = setTimeFromIso(cursor, e.start);
|
||||||
|
const occRemindIso = setTimeFromIso(cursor, e.remindAt || e.start);
|
||||||
|
out.push({ ...e, start: occStartIso, remindAt: occRemindIso });
|
||||||
|
}
|
||||||
|
cursor = new Date(cursor.getTime() + 24*3600*1000);
|
||||||
|
|
||||||
|
// Safety break
|
||||||
|
if (out.length > 5000) {
|
||||||
|
console.warn('Excessive recurrence detected, truncating.', e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function computeUpcomingReminders(e, sinceMs, untilMs) {
|
||||||
|
const rec = parseRecurrenceStr(e.recurrence);
|
||||||
|
const evStart = new Date(e.start);
|
||||||
|
const evEnd = e.end ? new Date(e.end) : new Date(evStart.getTime() + 60*60*1000);
|
||||||
|
const reminders = [];
|
||||||
|
if (rec.type === 'NONE') {
|
||||||
|
const iso = e.remindAt || e.start;
|
||||||
|
if (!iso) return reminders;
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
const startDayMs = Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate());
|
||||||
|
const endDayMs = Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate());
|
||||||
|
const windowStartDayMs = Date.UTC(new Date(sinceMs).getUTCFullYear(), new Date(sinceMs).getUTCMonth(), new Date(sinceMs).getUTCDate());
|
||||||
|
const windowEndDayMs = Date.UTC(new Date(untilMs).getUTCFullYear(), new Date(untilMs).getUTCMonth(), new Date(untilMs).getUTCDate());
|
||||||
|
const fromDay = Math.max(startDayMs, windowStartDayMs);
|
||||||
|
const toDay = Math.min(endDayMs, windowEndDayMs);
|
||||||
|
const timeSourceIso = e.remindAt || e.start;
|
||||||
|
for (let d = fromDay; d <= toDay; d += 24*3600*1000) {
|
||||||
|
const cursor = new Date(d);
|
||||||
|
if (rec.type === 'DAILY') {
|
||||||
|
const diffDays = Math.floor((d - startDayMs)/(24*3600*1000));
|
||||||
|
if (diffDays % rec.interval === 0) {
|
||||||
|
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
}
|
||||||
|
} else if (rec.type === 'WEEKLY') {
|
||||||
|
const weekday = cursor.getUTCDay() === 0 ? 7 : cursor.getUTCDay();
|
||||||
|
const weeksSinceStart = Math.floor((d - startDayMs)/(7*24*3600*1000));
|
||||||
|
const by = rec.by.length ? rec.by : [((new Date(e.start)).getDay() || 7)];
|
||||||
|
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||||
|
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
async function prefetchAdjacentMonths(year, month) {
|
||||||
|
try {
|
||||||
|
const prevY = month === 1 ? year - 1 : year;
|
||||||
|
const prevM = month === 1 ? 12 : month - 1;
|
||||||
|
const nextY = month === 12 ? year + 1 : year;
|
||||||
|
const nextM = month === 12 ? 1 : month + 1;
|
||||||
|
const pk = `${prevY}-${prevM}`;
|
||||||
|
const nk = `${nextY}-${nextM}`;
|
||||||
|
if (!monthCache.has(pk)) {
|
||||||
|
SyncAPI.listByMonth(prevY, prevM).then(d => monthCache.set(pk, d)).catch(() => {});
|
||||||
|
}
|
||||||
|
if (!monthCache.has(nk)) {
|
||||||
|
SyncAPI.listByMonth(nextY, nextM).then(d => monthCache.set(nk, d)).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 数据模型与工具
|
||||||
|
const Models = (function () {
|
||||||
|
function uuid() {
|
||||||
|
return (crypto && crypto.randomUUID) ? crypto.randomUUID() : (
|
||||||
|
Date.now() + '-' + Math.random().toString(36).slice(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowISO() { return new Date().toISOString(); }
|
||||||
|
|
||||||
|
function ensureEnd(startISO, endISO, allDay) {
|
||||||
|
if (endISO) return endISO;
|
||||||
|
const s = new Date(startISO);
|
||||||
|
const e = new Date(s.getTime() + (allDay ? 24 * 3600_000 : 60 * 60_000));
|
||||||
|
return e.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvent({ title, content, start, end, allDay, remindAt, recurrence, completed }) {
|
||||||
|
const createdAt = nowISO();
|
||||||
|
const ev = {
|
||||||
|
id: uuid(),
|
||||||
|
title: (title || '').trim(),
|
||||||
|
content: (content || '').trim(),
|
||||||
|
start,
|
||||||
|
end: ensureEnd(start, end, !!allDay),
|
||||||
|
allDay: !!allDay,
|
||||||
|
remindAt: remindAt || '',
|
||||||
|
recurrence: recurrence || '',
|
||||||
|
completed: !!completed,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
deleted: false,
|
||||||
|
};
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForUpdate(ev) {
|
||||||
|
ev.updatedAt = nowISO();
|
||||||
|
if (!ev.end) ev.end = ensureEnd(ev.start, ev.end, ev.allDay);
|
||||||
|
ev.title = (ev.title || '').trim();
|
||||||
|
ev.content = (ev.content || '').trim();
|
||||||
|
ev.completed = !!ev.completed;
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDay(isoA, isoB) {
|
||||||
|
const a = new Date(isoA), b = new Date(isoB);
|
||||||
|
return a.getUTCFullYear() === b.getUTCFullYear() &&
|
||||||
|
a.getUTCMonth() === b.getUTCMonth() &&
|
||||||
|
a.getUTCDate() === b.getUTCDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createEvent, normalizeForUpdate, isSameDay };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.Models = Models;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// 简单的 localStorage 心跳锁实现(兼容性回退)
|
||||||
|
// 若浏览器支持 BroadcastChannel,可替换为更即时的通讯方式
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const LOCK_KEY = 'calendar_instance_lock';
|
||||||
|
const HEARTBEAT_INTERVAL = 2000; // ms
|
||||||
|
const STALE_THRESHOLD = 5000; // ms
|
||||||
|
|
||||||
|
const instanceId = Date.now() + '-' + Math.random().toString(36).slice(2);
|
||||||
|
|
||||||
|
function now() { return Date.now(); }
|
||||||
|
|
||||||
|
function tryAcquire() {
|
||||||
|
const raw = localStorage.getItem(LOCK_KEY);
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
if (now() - obj.heartbeat < STALE_THRESHOLD) {
|
||||||
|
// 有活跃实例
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// malformed, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 申请锁
|
||||||
|
localStorage.setItem(LOCK_KEY, JSON.stringify({ id: instanceId, heartbeat: now() }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heartbeat() {
|
||||||
|
const raw = localStorage.getItem(LOCK_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
if (obj.id === instanceId) {
|
||||||
|
obj.heartbeat = now();
|
||||||
|
localStorage.setItem(LOCK_KEY, JSON.stringify(obj));
|
||||||
|
} else {
|
||||||
|
// 被其他实例抢占,通知并退出
|
||||||
|
alert('你好,每次只能运行一个程序。你已经运行了。');
|
||||||
|
cleanupAndExit();
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupAndExit() {
|
||||||
|
try { // 尝试清理自己持有的锁
|
||||||
|
const raw = localStorage.getItem(LOCK_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
if (obj.id === instanceId) {
|
||||||
|
localStorage.removeItem(LOCK_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
// 停止应用初始化(这里简单重载或关闭页面)
|
||||||
|
window.close && window.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tryAcquire()) {
|
||||||
|
alert('你好,每次只能运行一个程序。你已经运行了。');
|
||||||
|
// 直接阻止后续初始化
|
||||||
|
throw new Error('Single instance active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时心跳
|
||||||
|
const hbTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
|
||||||
|
|
||||||
|
// 在页面卸载时释放锁
|
||||||
|
window.addEventListener('beforeunload', function () {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCK_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
if (obj.id === instanceId) {
|
||||||
|
localStorage.removeItem(LOCK_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
clearInterval(hbTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ... 应用其他初始化继续 ...
|
||||||
|
})();
|
||||||