refactor(structure): reorganize app layout

BREAKING CHANGE: root backend/frontend directories and old run/manage entrypoints were removed. Use apps/backend, apps/frontend, and python main.py commands instead.
This commit is contained in:
2026-05-03 16:43:11 +08:00
parent 7e8852877e
commit d4d6f87730
112 changed files with 347 additions and 1596 deletions
@@ -0,0 +1,84 @@
/**
* 通用异步操作 Composable
* 统一处理 loading、error 状态和消息提示
*
* @example
* const { loading, error, execute } = useAsyncAction()
*
* const handleSubmit = async () => {
* await execute(
* () => api.createTask(formData),
* { successMsg: '创建成功', errorMsg: '创建失败' }
* )
* }
*/
import { ref } from 'vue';
import { message } from 'ant-design-vue';
export function useAsyncAction(options = {}) {
const loading = ref(false);
const error = ref(null);
/**
* 执行异步操作
* @param {Function} asyncFn - 异步函数
* @param {Object} config - 配置选项
* @param {string} config.successMsg - 成功提示消息
* @param {string} config.errorMsg - 错误提示消息
* @param {boolean} config.throwOnError - 是否抛出错误
* @param {boolean} config.silent - 是否静默模式(不显示消息)
* @returns {Promise} 异步函数的返回值
*/
const execute = async (asyncFn, config = {}) => {
const {
successMsg = options.successMsg,
errorMsg = options.errorMsg,
throwOnError = false,
silent = false,
} = config;
loading.value = true;
error.value = null;
try {
const result = await asyncFn();
if (!silent && successMsg) {
message.success({ content: successMsg, duration: 3 });
}
return result;
} catch (err) {
error.value = err;
if (!silent) {
const msg = err.message || err.detail || errorMsg || '操作失败';
message.error({ content: msg, duration: 4 });
}
if (throwOnError) {
throw err;
}
return null;
} finally {
loading.value = false;
}
};
/**
* 重置状态
*/
const reset = () => {
loading.value = false;
error.value = null;
};
return {
loading,
error,
execute,
reset,
};
}
@@ -0,0 +1,65 @@
import { ref, onMounted, onUnmounted } from 'vue';
/**
* 响应式断点检测 Composable
* 基于 Ant Design 的断点系统
* - xs: <576px (手机)
* - sm: ≥576px (平板竖屏)
* - md: ≥768px (平板横屏)
* - lg: ≥992px (桌面)
* - xl: ≥1200px (大屏)
* - xxl: ≥1600px (超大屏)
*/
export function useBreakpoint() {
const isMobile = ref(window.innerWidth < 768);
const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 992);
const isDesktop = ref(window.innerWidth >= 992);
// Ant Design 断点
const isXs = ref(window.innerWidth < 576);
const isSm = ref(window.innerWidth >= 576 && window.innerWidth < 768);
const isMd = ref(window.innerWidth >= 768 && window.innerWidth < 992);
const isLg = ref(window.innerWidth >= 992 && window.innerWidth < 1200);
const isXl = ref(window.innerWidth >= 1200 && window.innerWidth < 1600);
const isXxl = ref(window.innerWidth >= 1600);
const updateBreakpoints = () => {
const width = window.innerWidth;
// 简化断点
isMobile.value = width < 768;
isTablet.value = width >= 768 && width < 992;
isDesktop.value = width >= 992;
// Ant Design 断点
isXs.value = width < 576;
isSm.value = width >= 576 && width < 768;
isMd.value = width >= 768 && width < 992;
isLg.value = width >= 992 && width < 1200;
isXl.value = width >= 1200 && width < 1600;
isXxl.value = width >= 1600;
};
onMounted(() => {
window.addEventListener('resize', updateBreakpoints);
});
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoints);
});
return {
// 简化断点(常用)
isMobile,
isTablet,
isDesktop,
// Ant Design 断点(详细)
isXs,
isSm,
isMd,
isLg,
isXl,
isXxl,
};
}
@@ -0,0 +1,124 @@
/**
* 状态轮询 Composable
* 支持指数退避、最大重试次数、自动清理
*
* @example
* const { polling, startPolling, stopPolling } = usePollStatus({
* interval: 2000,
* maxRetries: 15,
* backoff: true
* })
*
* startPolling(
* async () => {
* const status = await api.getStatus(id)
* return {
* completed: status.status !== 'pending',
* success: status.status === 'success',
* data: status
* }
* },
* {
* onSuccess: (result) => console.log('完成', result),
* onFailure: (error) => console.error('失败', error),
* onTimeout: () => console.warn('超时')
* }
* )
*/
import { ref, onUnmounted } from 'vue';
export function usePollStatus(options = {}) {
const {
interval = 2000, // 初始轮询间隔(毫秒)
maxRetries = 15, // 最大重试次数
backoff = false, // 是否使用指数退避
maxBackoffInterval = 10000, // 最大退避间隔(毫秒)
} = options;
const polling = ref(false);
let pollTimer = null;
let retryCount = 0;
/**
* 开始轮询
* @param {Function} checkFn - 检查函数,应返回 { completed, success, data }
* @param {Object} callbacks - 回调函数
* @param {Function} callbacks.onSuccess - 成功回调
* @param {Function} callbacks.onFailure - 失败回调
* @param {Function} callbacks.onTimeout - 超时回调
*/
const startPolling = async (checkFn, callbacks = {}) => {
const { onSuccess, onFailure, onTimeout } = callbacks;
// 重置状态
stopPolling();
polling.value = true;
retryCount = 0;
const poll = async () => {
try {
const result = await checkFn();
// 检查是否完成
if (result.completed) {
stopPolling();
if (result.success) {
onSuccess?.(result.data || result);
} else {
onFailure?.(result.data || result);
}
return;
}
// 检查是否超时
retryCount++;
if (retryCount >= maxRetries) {
stopPolling();
onTimeout?.();
return;
}
// 计算下次轮询间隔(支持指数退避)
let nextInterval = interval;
if (backoff) {
// 指数退避:2s -> 4s -> 8s -> 最大10s
nextInterval = Math.min(interval * Math.pow(2, retryCount - 1), maxBackoffInterval);
}
// 继续轮询
pollTimer = setTimeout(poll, nextInterval);
} catch (error) {
stopPolling();
onFailure?.(error);
}
};
// 立即执行第一次检查
poll();
};
/**
* 停止轮询
*/
const stopPolling = () => {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
polling.value = false;
retryCount = 0;
};
// 组件卸载时自动清理
onUnmounted(() => {
stopPolling();
});
return {
polling,
startPolling,
stopPolling,
};
}
+106
View File
@@ -0,0 +1,106 @@
import { ref, computed } from 'vue';
const THEME_STORAGE_KEY = 'checkin-app-theme';
// 全局主题状态(单例模式)
const theme = ref('light');
/**
* 应用主题到 DOM
*/
const applyTheme = newTheme => {
const html = document.documentElement;
if (newTheme === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
};
/**
* 初始化主题
* 优先级: localStorage > 系统偏好 > 默认亮色
*/
export const initTheme = () => {
// 1. 尝试从 localStorage 读取
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme === 'light' || savedTheme === 'dark') {
theme.value = savedTheme;
applyTheme(savedTheme);
return;
}
// 2. 检测系统偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme.value = 'dark';
applyTheme('dark');
return;
}
// 3. 默认亮色
theme.value = 'light';
applyTheme('light');
};
/**
* 监听系统主题变化
*/
export const watchSystemTheme = () => {
if (!window.matchMedia) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = e => {
// 仅在用户未手动设置主题时才跟随系统
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (!savedTheme) {
const systemTheme = e.matches ? 'dark' : 'light';
theme.value = systemTheme;
applyTheme(systemTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
// 返回清理函数
return () => mediaQuery.removeEventListener('change', handleChange);
};
/**
* 主题管理 Composable
* 支持亮色/暗色模式切换,并持久化到 localStorage
*/
export function useTheme() {
/**
* 切换主题
*/
const toggleTheme = () => {
const newTheme = theme.value === 'light' ? 'dark' : 'light';
theme.value = newTheme;
applyTheme(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
};
/**
* 设置指定主题
*/
const setTheme = newTheme => {
if (newTheme !== 'light' && newTheme !== 'dark') {
console.warn(`Invalid theme: ${newTheme}. Using 'light' instead.`);
newTheme = 'light';
}
theme.value = newTheme;
applyTheme(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
};
return {
theme,
toggleTheme,
setTheme,
isDark: computed(() => theme.value === 'dark'),
isLight: computed(() => theme.value === 'light'),
};
}
@@ -0,0 +1,190 @@
import { computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
/**
* Token 过期监控 Composable
*
* 功能:
* 1. 定时检查 Token 状态
* 2. Token 过期后 5 分钟内提醒用户
* 3. 为有密码的用户提供友好的过期处理
*
* 注意:使用单例模式,确保全局只有一个监控实例
*/
// 全局单例:确保整个应用只有一个监控实例
let monitorTimer = null;
let warningShown = false;
let isMonitoring = false; // 新增:防止重复启动
// 检查间隔(毫秒)
const NORMAL_CHECK_INTERVAL = 15 * 60 * 1000; // 正常情况:15 分钟
const URGENT_CHECK_INTERVAL = 5 * 60 * 1000; // Token 即将过期:5 分钟
export function useTokenMonitor() {
const authStore = useAuthStore();
const userStore = useUserStore();
const router = useRouter();
const tokenStatus = computed(() => userStore.tokenStatus);
const hasPassword = computed(() => authStore.user?.has_password || false);
// 计算 Token 剩余分钟数
const getRemainingMinutes = () => {
if (!tokenStatus.value?.expires_at) return null;
const now = Math.floor(Date.now() / 1000);
const expiresAt = tokenStatus.value.expires_at;
const diffSeconds = expiresAt - now;
return Math.floor(diffSeconds / 60);
};
// 检查 Token 状态并显示提醒
const checkTokenStatus = async () => {
// 如果未登录,不检查
if (!authStore.isAuthenticated) {
return;
}
try {
// 获取最新的 Token 状态
await userStore.fetchTokenStatus();
const remainingMinutes = getRemainingMinutes();
// Token 已过期(负数分钟)
if (remainingMinutes !== null && remainingMinutes < 0) {
const expiredMinutes = Math.abs(remainingMinutes);
// Token 过期后 5 分钟内提醒
if (expiredMinutes <= 5) {
if (hasPassword.value) {
// 有密码的用户:友好提示
if (!warningShown) {
message.warning({
content: `您的 Token 已过期 ${expiredMinutes} 分钟,部分功能可能受限。建议您扫码刷新凭证。`,
duration: 3,
key: 'token-expired-warning',
});
warningShown = true;
}
} else {
// 没有密码的用户:必须重新登录
message.error({
content: '您的 Token 已过期,请重新扫码登录',
duration: 5,
key: 'token-expired-error',
});
// 清除登录状态并跳转
authStore.logout();
router.push('/login');
}
} else if (expiredMinutes > 5) {
// 过期超过 5 分钟
if (!hasPassword.value) {
// 没有密码的用户:强制退出
authStore.logout();
router.push('/login');
}
}
}
// Token 即将过期(1小时内)
else if (remainingMinutes !== null && remainingMinutes > 0 && remainingMinutes <= 60) {
if (!warningShown) {
message.warning({
content: `您的 Token 将在 ${remainingMinutes} 分钟后过期,建议您及时刷新`,
duration: 3,
key: 'token-expiring-warning',
});
warningShown = true;
}
// Token 即将过期时,切换到更频繁的检查(5 分钟)
adjustCheckInterval(URGENT_CHECK_INTERVAL);
}
// Token 状态正常
else if (remainingMinutes !== null && remainingMinutes > 60) {
// 重置警告标志
warningShown = false;
// 恢复正常检查频率(15 分钟)
adjustCheckInterval(NORMAL_CHECK_INTERVAL);
}
} catch (error) {
console.error('检查 Token 状态失败:', error);
}
};
// 调整检查间隔
const adjustCheckInterval = newInterval => {
if (monitorTimer) {
const currentInterval = monitorTimer._idleTimeout || 0;
// 只有当新间隔与当前间隔不同时才重启定时器
if (currentInterval !== newInterval) {
clearInterval(monitorTimer);
monitorTimer = setInterval(() => {
checkTokenStatus();
}, newInterval);
}
}
};
// 启动监控
const startMonitoring = () => {
// 避免重复启动(单例模式)
if (isMonitoring || monitorTimer) {
return;
}
isMonitoring = true;
// 立即检查一次
checkTokenStatus();
// 默认使用正常检查频率(15 分钟)
monitorTimer = setInterval(() => {
checkTokenStatus();
}, NORMAL_CHECK_INTERVAL);
};
// 停止监控
const stopMonitoring = () => {
if (monitorTimer) {
clearInterval(monitorTimer);
monitorTimer = null;
}
isMonitoring = false;
warningShown = false;
};
// 手动触发检查
const checkNow = () => {
warningShown = false; // 重置警告标志,允许再次显示
checkTokenStatus();
};
// 组件挂载时启动监控
onMounted(() => {
if (authStore.isAuthenticated) {
startMonitoring();
}
});
// 组件卸载时不停止监控(因为是全局单例)
// onUnmounted 中不调用 stopMonitoring(),让监控持续运行
return {
tokenStatus,
hasPassword,
startMonitoring,
stopMonitoring,
checkNow,
getRemainingMinutes,
};
}