feat: Architecture initialization

This commit is contained in:
2026-07-14 10:31:17 +08:00
commit 6f37264047
59 changed files with 8752 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import { defineComponent } from 'vue';
import { ConfigProvider } from 'ant-design-vue';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import { RouterView } from 'vue-router';
/**
* 根组件
* 通过 ConfigProvider 注入 ant-design-vue 全局配置(语言、主题色)
*/
export default defineComponent({
name: 'App',
setup() {
const theme = {
token: {
colorPrimary: '#1677ff',
},
};
return () => (
<ConfigProvider locale={zhCN} theme={theme}>
<RouterView />
</ConfigProvider>
);
},
});
+29
View File
@@ -0,0 +1,29 @@
/**
* 接口模块示例
* 实际项目中按业务域拆分:如 api/user.ts、api/order.ts 等
*/
import { get, post } from '@/utils/request';
export interface UserInfo {
id: number;
name: string;
role: string;
}
/** 获取当前用户信息(示例接口) */
export function getCurrentUser(): Promise<UserInfo> {
return get('/user/current');
}
/** 用户列表(示例接口) */
export function getUserList(params: {
page: number;
size: number;
}): Promise<{ list: UserInfo[]; total: number }> {
return get('/user/list', params);
}
/** 新建用户(示例接口) */
export function createUser(data: Omit<UserInfo, 'id'>): Promise<UserInfo> {
return post('/user', data);
}
+57
View File
@@ -0,0 +1,57 @@
// ===== 全局样式入口 =====
// CSS Reset 已由 ant-design-vue 的 reset.css 提供,这里补充全局变量与基础样式
// 全局 less 变量
@primary-color: #1677ff;
@text-color: rgba(0, 0, 0, 0.88);
@border-color: #f0f0f0;
@bg-color: #f5f5f5;
// 基础重置
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: 14px;
color: @text-color;
background-color: @bg-color;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: @primary-color;
text-decoration: none;
&:hover {
opacity: 0.85;
}
}
// 滚动条美化
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
+19
View File
@@ -0,0 +1,19 @@
// ===== 主题变量 =====
// 可在组件中通过 @import 引入复用
@primary-color: #1677ff;
@success-color: #52c41a;
@warning-color: #faad14;
@error-color: #ff4d4f;
@text-color: rgba(0, 0, 0, 0.88);
@text-color-secondary: rgba(0, 0, 0, 0.65);
@text-color-disabled: rgba(0, 0, 0, 0.35);
@border-radius-base: 6px;
@border-color: #f0f0f0;
@bg-color: #f5f5f5;
@layout-header-height: 48px;
@layout-sider-width: 200px;
@layout-sider-width-collapsed: 80px;
+16
View File
@@ -0,0 +1,16 @@
.container {
text-align: center;
padding: 16px;
}
.title {
color: #1677ff;
}
.desc {
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineComponent } from 'vue';
import styles from './HelloWorld.module.less';
/**
* 公共组件示例
*/
export default defineComponent({
name: 'HelloWorld',
props: {
msg: {
type: String,
default: 'Hello World',
},
},
setup(props) {
return () => (
<div class={styles.container}>
<h3 class={styles.title}>{props.msg}</h3>
<p class={styles.desc}>
, TSX
<code>src/components</code>
</p>
</div>
);
},
});
+5
View File
@@ -0,0 +1,5 @@
/**
* 公共组件统一导出
* 在业务中可通过 `import { HelloWorld } from '@/components'` 使用
*/
export { default as HelloWorld } from './HelloWorld';
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
readonly VITE_BASE_URL: string;
readonly VITE_PORT: string;
readonly VITE_API_BASE_URL: string;
readonly VITE_API_PREFIX: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+10
View File
@@ -0,0 +1,10 @@
export * from './useEffect';
export * from './useState';
export * from './usePagination';
export * from './useRequest';
export * from './useDebounce';
export * from './useAuth';
export * from './useWebSocket';
export * from './useBasicLayout';
export * from './useBreadcrumb';
export * from './useContext';
+34
View File
@@ -0,0 +1,34 @@
// src/hooks/useAuth.ts
import { useState } from './useState';
import { watch } from 'vue';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
function createAuth() {
const [token, setToken] = useState<string | null>(window.localStorage.getItem(TOKEN_KEY));
watch(token, (newToken) => {
if (newToken) {
window.localStorage.setItem(TOKEN_KEY, newToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
});
const login = (newToken: string) => {
setToken(newToken);
};
const logout = () => {
setToken(null);
};
return {
token,
isLoggedIn: () => !!token.value,
login,
logout,
};
}
export const auth = createAuth();
+46
View File
@@ -0,0 +1,46 @@
import { h } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { TrophyOutlined, WalletOutlined } from '@ant-design/icons-vue';
import { useBreadcrumb } from './useBreadcrumb';
export function useBasicLayout() {
const router = useRouter();
const route = useRoute();
const { breadcrumbItems } = useBreadcrumb();
const menuItems = [
{
key: '/events',
icon: h(TrophyOutlined),
label: '我的赛事',
},
// { TODO 本期先做隐藏
// key: '/wallet',
// icon: h(WalletOutlined),
// label: '钱包管理',
// },
];
const getActiveMenuKey = () => {
const matchedRoutes = route.matched;
for (const r of matchedRoutes) {
if (r.meta?.activeMenu) {
return r.meta.activeMenu as string;
}
}
if (route.path.startsWith('/events')) return '/events';
if (route.path.startsWith('/wallet')) return '/wallet';
return route.path;
};
const handleMenuClick = (info: any) => {
router.push(String(info.key));
};
return {
breadcrumbItems,
menuItems,
getActiveMenuKey,
handleMenuClick,
};
}
+67
View File
@@ -0,0 +1,67 @@
import { computed, h } from 'vue';
import { useRoute, useRouter } from 'vue-router';
export function useBreadcrumb() {
const route = useRoute();
const router = useRouter();
/**
* 根据当前路径和记录路径,计算拼接后的路径
*/
const buildPath = (currentPath: string, recordPath: string): string => {
if (recordPath.startsWith('/')) {
return recordPath;
}
return currentPath ? `${currentPath}/${recordPath}`.replace(/\/+/g, '/') : `/${recordPath}`;
};
const breadcrumbs = computed(() => {
const matched = route.matched;
const items: Array<{ title: string; path?: string }> = [];
let currentPath = '';
for (const record of matched) {
currentPath = buildPath(currentPath, record.path);
if (record.meta && record.meta.title) {
items.push({
title: record.meta.title as string,
path: currentPath,
});
}
}
return items;
});
const breadcrumbItems = computed(() => {
return breadcrumbs.value.map((item, index) => ({
key: index,
title:
item.path && index !== breadcrumbs.value.length - 1
? h(
'a',
{
onClick: (e: Event) => {
e.preventDefault();
// 最保守方案:只有当前路径包含 /bracket/ 且目标路径是 /events/bracket 时才保留 query
// 也就是只有:/events/bracket/xxx → /events/bracket 时才保留
const targetPath = item.path || '/';
const isBracketChildPage =
route.path.includes('/bracket/') && targetPath === '/events/bracket';
router.push({
path: targetPath,
query: isBracketChildPage ? route.query : {},
});
},
},
item.title,
)
: item.title,
}));
});
return {
breadcrumbs,
breadcrumbItems,
};
}
+137
View File
@@ -0,0 +1,137 @@
import { reactive, provide, inject, type InjectionKey } from 'vue';
/**
* 深合并:将 source 的属性递归合并到 target 中
* - 对于原始值和数组,直接赋值(数组整体替换,不做元素级 diff)
* - 对于普通对象,递归合并,保留 target 中已有的响应式 Proxy
* - 跳过值为 undefined 的属性
*/
function deepSyncState<T extends Record<string, any>>(target: T, source: Partial<T>): void {
const keys = Object.keys(source) as (keyof T)[];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!(key in target) || source[key] === undefined) continue;
const sourceVal = source[key];
const targetVal = target[key];
// 两边都是普通对象 → 递归合并,保留响应式
if (
isPlainObject(sourceVal) &&
isPlainObject(targetVal) &&
!Array.isArray(sourceVal) &&
!Array.isArray(targetVal)
) {
deepSyncState(targetVal as Record<string, any>, sourceVal as Record<string, any>);
} else {
// 原始值、数组、或类型不匹配 → 直接赋值
(target as any)[key] = sourceVal;
}
}
}
/**
* 判断是否为普通对象(Plain Object
* 排除 Array、Date、RegExp、Map、Set 等内置类型
*/
function isPlainObject(val: unknown): val is Record<string, any> {
if (val === null || typeof val !== 'object') return false;
const proto = Object.getPrototypeOf(val);
// Object.create(null) 的 proto 为 null,也视为普通对象
return proto === null || proto === Object.prototype;
}
export interface Context<T extends Record<string, any>> {
/**
* 在当前组件及所有子组件中提供 Context 数据
*
* @param value - 可选的部分更新,会深合并到初始值中
* @returns dispose 清理函数,调用后恢复初始值
*/
provide: (value?: Partial<T>) => () => void;
/** 在子组件中消费 Context 数据,返回响应式对象 */
useContext: () => T;
/** 底层 InjectionKey,用于自定义 provide/inject 场景 */
readonly key: InjectionKey<T>;
}
/**
* 创建一个 Context 实例
*
* @param initialValue - 初始状态对象,所有属性将具备响应式能力
* @param options.displayName - 可选,用于 DevTools 调试时的标识
*/
export function createContext<T extends Record<string, any>>(
initialValue: T,
options?: { displayName?: string },
): Context<T> {
const name = options?.displayName || 'Context';
const key: InjectionKey<T> = Symbol(name) as InjectionKey<T>;
// 深拷贝初始值,确保源数据不被污染,同时作为后续恢复的快照
const snapshot = deepClone(initialValue);
// 创建全局响应式容器
const initialReactive = reactive(snapshot) as T;
// 记录当前是否已被 dispose,防止重复调用
let disposed = false;
return {
key,
/**
* 在当前组件树中提供 Context
* 调用后,所有子组件可通过 useContext() 获取到响应式状态
*
* @param value - 可选的部分更新,会深合并到初始值中
* @returns 清理函数,调用后将状态恢复为 initialValue
*/
provide(value?: Partial<T>): () => void {
disposed = false;
// 如果传入了 value,深合并到全局 reactive 对象
if (value) {
deepSyncState(initialReactive, value);
}
// 通过 Vue 原生 provide 向下注入
provide(key, initialReactive);
// 返回清理函数
return () => {
if (disposed) return;
disposed = true;
// 恢复初始值
deepSyncState(initialReactive, initialValue);
};
},
/**
* 在子组件中消费 Context
* 必须在 Provider 所在组件的子级 setup 中调用
*/
useContext(): T {
const injected = inject(key);
if (!injected) {
// 降级返回初始值,保证组件不会崩溃
return initialReactive;
}
return injected;
},
};
}
/**
* 深拷贝普通对象,保留嵌套结构
* 仅用于创建初始快照,不处理特殊对象(Date、RegExp 等保持引用)
*/
function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T;
const result = {} as T;
const keys = Object.keys(obj) as (keyof T)[];
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
result[k] = deepClone(obj[k]);
}
return result;
}
+80
View File
@@ -0,0 +1,80 @@
// src/hooks/useDebounce.ts
import { watch, onUnmounted } from 'vue';
import { useState } from './useState';
export interface UseDebounceOptions {
delay?: number;
immediate?: boolean;
maxWait?: number;
}
export function useDebounce<T>(
source: any, // Ref<T>
options: number | UseDebounceOptions = {},
) {
const config = typeof options === 'number' ? { delay: options } : options;
const { delay = 300, immediate = false, maxWait } = config;
const [debouncedValue, setDebouncedValue] = useState<T>(source.value);
const [isPending, setIsPending] = useState(false);
let timer: any = null;
let maxWaitTimer: any = null;
let isFirstCall = true; // 标记是否为首次调用,用于 immediate 模式
// 清理
const clearTimers = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (maxWaitTimer) {
clearTimeout(maxWaitTimer);
maxWaitTimer = null;
}
};
// 更新
const updateValue = () => {
setDebouncedValue(source.value);
setIsPending(false);
clearTimers();
// 安静期结束后重置 isFirstCall,使下次触发能立即执行(immediate 模式)
isFirstCall = true;
};
watch(source, () => {
setIsPending(true);
// 处理 maxWait
if (maxWait && !maxWaitTimer) {
maxWaitTimer = setTimeout(() => {
updateValue();
}, maxWait);
}
// 处理 delay
if (timer) clearTimeout(timer);
if (immediate && isFirstCall) {
isFirstCall = false;
updateValue();
} else {
timer = setTimeout(() => {
updateValue();
}, delay);
}
});
// 卸载
onUnmounted(() => {
clearTimers();
});
return {
debouncedValue,
isPending,
refresh: updateValue,
cancel: clearTimers,
};
}
+32
View File
@@ -0,0 +1,32 @@
import { watchEffect, watch, WatchSource, isRef, isReactive } from 'vue';
export function useEffect(effect: () => void | (() => void), deps?: WatchSource<any>[]) {
if (!deps) {
return watchEffect((onCleanup) => {
const cleanup = effect();
if (cleanup) onCleanup(cleanup);
});
}
const validDeps = deps.map((dep) => {
if (dep === null || dep === undefined) {
return () => null;
}
if (isRef(dep) || isReactive(dep) || typeof dep === 'function') {
return dep;
}
return () => dep;
});
return watch(
validDeps,
() => {
const cleanup = effect();
return cleanup;
},
{
immediate: true,
flush: 'post',
},
);
}
+185
View File
@@ -0,0 +1,185 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// src/hooks/usePagination.ts
import { computed, ref, watch, Ref, triggerRef } from 'vue';
/**
* 分页配置项
*/
export interface UsePaginationOptions {
/** 当前页码 (支持 v-model 双向绑定) */
current?: number;
/** 每页条数 (支持 v-model 双向绑定) */
pageSize?: number;
/** 数据总条数 (支持 v-model 双向绑定) */
total?: number;
/** 默认页码 (非受控模式下使用) */
defaultCurrent?: number;
/** 默认每页条数 (非受控模式下使用) */
defaultPageSize?: number;
/** 每页条数切换选项 (用于生成下拉菜单) */
pageSizeOptions?: number[];
/** 是否显示快速跳转 (可选功能) */
showQuickJumper?: boolean;
}
/**
* 分页操作 API
*/
export interface UsePaginationActions {
/** 设置当前页 */
setCurrent: (current: number) => void;
/** 设置每页条数 (会自动重置页码为 1) */
setPageSize: (size: number) => void;
/** 设置总条数 */
setTotal: (total: number) => void;
/** 下一页 */
next: () => void;
/** 上一页 */
prev: () => void;
/** 跳转到指定页 */
jump: (page: number) => void;
/** 重置到初始状态 */
reset: () => void;
/** 强制刷新 (用于某些极端响应式场景) */
refresh: () => void;
}
/**
* 分页计算属性
*/
export interface UsePaginationComputed {
/** 响应式请求参数对象 (给 API 用的) */
params: Ref<{ page: number; pageSize: number }>;
/** 总页数 */
totalPages: Ref<number>;
/** 是否有上一页 */
hasPrev: Ref<boolean>;
/** 是否有下一页 */
hasNext: Ref<boolean>;
/** 当前页起始索引 (用于显示 "显示 1-10 条") */
startIndex: Ref<number>;
/** 当前页结束索引 */
endIndex: Ref<number>;
/** 是否为第一页 */
isFirstPage: Ref<boolean>;
/** 是否为最后一页 */
isLastPage: Ref<boolean>;
}
export type UsePaginationReturn = UsePaginationOptions &
UsePaginationActions &
UsePaginationComputed;
export function usePagination(options: UsePaginationOptions = {}): UsePaginationReturn {
const { defaultCurrent = 1, defaultPageSize = 10, pageSizeOptions = [10, 20, 50, 100] } = options;
const currentRef = ref(options.current ?? defaultCurrent);
const pageSizeRef = ref(options.pageSize ?? defaultPageSize);
const totalRef = ref(options.total ?? 0);
watch(
() => options.current,
(val) => {
if (val !== undefined) currentRef.value = val;
},
);
watch(
() => options.pageSize,
(val) => {
if (val !== undefined) pageSizeRef.value = val;
},
);
watch(
() => options.total,
(val) => {
if (val !== undefined) totalRef.value = val;
},
);
const totalPages = computed(() => {
const total = totalRef.value;
const size = pageSizeRef.value;
return size === 0 ? 0 : Math.ceil(total / size);
});
const hasPrev = computed(() => currentRef.value > 1);
const hasNext = computed(() => currentRef.value < totalPages.value);
const isFirstPage = computed(() => currentRef.value === 1);
const isLastPage = computed(() => currentRef.value === totalPages.value);
const startIndex = computed(() =>
totalPages.value === 0 ? 0 : (currentRef.value - 1) * pageSizeRef.value + 1,
);
const endIndex = computed(() => Math.min(currentRef.value * pageSizeRef.value, totalRef.value));
const params = computed(() => ({
page: currentRef.value,
pageSize: pageSizeRef.value,
}));
const setCurrent = (page: number) => {
const safePage = Math.max(1, Math.min(page, totalPages.value || 1));
if (currentRef.value !== safePage) {
currentRef.value = safePage;
}
};
const setPageSize = (size: number) => {
if (pageSizeRef.value !== size) {
pageSizeRef.value = size;
currentRef.value = 1;
}
};
const setTotal = (total: number) => {
totalRef.value = total;
const maxPage = Math.max(1, Math.ceil(total / pageSizeRef.value));
if (currentRef.value > maxPage) {
currentRef.value = maxPage;
}
};
const next = () => setCurrent(currentRef.value + 1);
const prev = () => setCurrent(currentRef.value - 1);
const jump = (page: number) => {
if (!Number.isInteger(page) || page < 1) return;
setCurrent(page);
};
const reset = () => {
currentRef.value = defaultCurrent;
pageSizeRef.value = defaultPageSize;
};
const refresh = () => {
triggerRef(currentRef);
};
return {
// @ts-ignore
current: currentRef,
// @ts-ignore
pageSize: pageSizeRef,
// @ts-ignore
total: totalRef,
pageSizeOptions,
params,
totalPages,
hasPrev,
hasNext,
isFirstPage,
isLastPage,
startIndex,
endIndex,
setCurrent,
setPageSize,
setTotal,
next,
prev,
jump,
reset,
refresh,
};
}
+170
View File
@@ -0,0 +1,170 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { ref, Ref, watch, WatchSource, onUnmounted, computed } from 'vue';
export interface UseRequestReturn<T> {
/** 纯净的数据,不包含后端外层壳 */
data: Ref<T | undefined>;
/** 加载状态 */
loading: Ref<boolean>;
/** 错误对象 */
error: Ref<Error | null>;
/** 手动触发请求 */
run: (...args: any[]) => Promise<T | undefined>;
/** 取消请求 */
cancel: () => void;
/** 重置数据 */
reset: () => void;
}
export function useRequest<T>(
fetcher: (...args: any[]) => Promise<any>,
options: {
/** 是否手动触发,默认 false (即自动触发) */
manual?: boolean;
/** 初始数据 */
initialData?: T;
/** 依赖数组,变化时重新请求 */
refreshDeps?: WatchSource[];
/**
* 数据格式化函数
* 默认逻辑:如果返回是对象且有 data 属性,则返回 res.data,否则返回原数据
*/
formatResult?: (res: any) => T;
/** 是否准备好可以发起请求,默认 true */
ready?: Ref<boolean>;
} = {},
): UseRequestReturn<T> & {
/** 获取当前 AbortController 的 signal,可传递给 fetcher */
getSignal: () => AbortSignal | undefined;
} {
const {
manual = false,
initialData,
refreshDeps,
formatResult = (res) => (res && typeof res === 'object' && 'data' in res ? res.data : res),
ready = ref(true),
} = options;
const data = ref<T | undefined>(initialData);
const loading = ref(false);
const error = ref<Error | null>(null);
let abortController: AbortController | null = null;
let isUnmounted = false;
onUnmounted(() => {
isUnmounted = true;
if (abortController) {
abortController.abort();
}
});
const run = async (...args: any[]): Promise<T | undefined> => {
if (isUnmounted) return undefined;
// 取消之前的请求
if (abortController) {
abortController.abort();
}
// 创建新的 AbortController
abortController = new AbortController();
const currentSignal = abortController.signal;
loading.value = true;
error.value = null;
try {
// 将 signal 作为最后一个参数传递给 fetcher
// 调用方可以通过最后一个参数获取 signal: async (...args, { signal }) => {...}
const res = await fetcher(...args, { signal: currentSignal });
if (currentSignal.aborted || isUnmounted) {
return undefined;
}
const formattedData = formatResult(res);
if (!isUnmounted) {
data.value = formattedData;
}
return formattedData;
} catch (e: any) {
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
return undefined;
}
if (!isUnmounted) {
error.value = e;
}
return undefined;
} finally {
// 只有当前请求未被取消时才重置 loading
if (!currentSignal.aborted && !isUnmounted) {
loading.value = false;
}
// 如果这是当前活动的 controller,清理引用
if (abortController?.signal === currentSignal) {
abortController = null;
}
}
};
const cancel = () => {
if (abortController) {
abortController.abort();
loading.value = false;
abortController = null;
}
};
const reset = () => {
cancel();
data.value = initialData;
error.value = null;
};
// 使用 shouldAutoRun 统一管理自动请求,避免 refreshDeps 和 immediate watch 重复触发 run
const shouldAutoRun = computed(() => !manual && ready.value);
let hasAutoRun = false; // 标记是否已自动执行过,防止重复触发
watch(
shouldAutoRun,
(val) => {
if (val && !hasAutoRun) {
hasAutoRun = true;
run();
} else if (!val) {
// 当条件不满足时重置 hasAutoRun,使下次满足条件时能再次自动执行
hasAutoRun = false;
}
},
{ immediate: true },
);
// refreshDeps 变化时重新请求(初始化时不再重复触发)
if (refreshDeps && refreshDeps.length > 0) {
watch(
refreshDeps,
() => {
if (shouldAutoRun.value) {
run();
}
},
{ deep: true },
);
}
const getSignal = () => abortController?.signal;
return {
// @ts-ignore
data,
loading,
error,
run,
cancel,
reset,
getSignal,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { ref, Ref } from 'vue';
/**
* 返回一个 [state, setState] 元组
*/
export function useState<T>(initialValue: T): [Ref<T>, (newVal: T) => void] {
const state: any = ref<T>(initialValue);
const setState = (newVal: T) => {
if (typeof newVal === 'function') {
state.value = (newVal as (prev: T) => T)(state.value);
} else {
state.value = newVal;
}
};
return [state, setState];
}
+245
View File
@@ -0,0 +1,245 @@
import { ref, watch, onBeforeUnmount, toValue } from 'vue';
import type { Ref, MaybeRefOrGetter } from 'vue';
// --- 类型定义 ---
export type WebSocketStatus = 'connecting' | 'open' | 'closed' | 'error';
export interface UseWebSocketOptions {
// 是否自动连接
autoConnect?: boolean;
// 子协议
protocols?: string[];
// --- 重连配置 ---
reconnectLimit?: number; // 最大重连次数,默认 -1 (无限)
reconnectInterval?: number; // 初始重连间隔 ms
reconnectIncrement?: number; // 每次重连增加的间隔 ms (用于指数退避)
// --- 心跳配置 ---
heartbeat?: boolean; // 是否开启心跳
heartbeatInterval?: number; // 心跳间隔 ms
heartbeatMessage?: any; // 心跳发送的数据
// --- 回调钩子 ---
onOpen?: (event: Event) => void;
onClose?: (event: CloseEvent) => void;
onMessage?: (data: any, event: MessageEvent) => void;
onError?: (event: Event) => void;
}
// --- 返回值接口 ---
export interface UseWebSocketReturn {
status: Ref<WebSocketStatus>;
latestData: Ref<any>; // 最新接收到的数据
send: (data: any) => void; // 发送方法
connect: () => void; // 手动连接
disconnect: () => void; // 手动断开
}
/**
* useWebSocket Hook
* 仿照 ahooks 设计,支持动态配置、指数退避重连、心跳检测
*/
// 模块级变量,替代 window 全局挂载,避免全局污染和内存泄漏
const webSocketList: WebSocket[] = [];
export function useWebSocket(
url: MaybeRefOrGetter<string>,
options: UseWebSocketOptions = {},
): UseWebSocketReturn {
const {
autoConnect = true,
protocols = [],
reconnectLimit = -1,
reconnectInterval = 1000,
reconnectIncrement = 1000,
heartbeat = true,
heartbeatInterval = 30000,
heartbeatMessage = 'ping',
onOpen,
onClose,
onMessage,
onError,
} = options;
// --- 响应式状态 ---
const status = ref<WebSocketStatus>('closed');
const latestData = ref<any>(null);
const wsRef = ref<WebSocket | null>(null);
// --- 内部变量 ---
let reconnectCount = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// --- 辅助函数 ---
// 清除所有定时器
const clearTimers = () => {
if (reconnectTimer) clearTimeout(reconnectTimer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
};
// 启动心跳
const startHeartbeat = () => {
if (!heartbeat) return;
stopHeartbeat(); // 防止重复启动
heartbeatTimer = setInterval(() => {
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
wsRef.value.send(
typeof heartbeatMessage === 'string'
? heartbeatMessage
: JSON.stringify(heartbeatMessage),
);
}
}, heartbeatInterval);
};
// 停止心跳
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
// 建立连接的核心逻辑
const connect = () => {
// 如果已经有连接且是开启状态,不重复创建
if (wsRef.value?.readyState === WebSocket.OPEN) return;
// 关闭旧连接(如果有)
if (wsRef.value) {
wsRef.value.close();
wsRef.value = null;
}
status.value = 'connecting';
try {
// 使用 toValue 支持响应式 URL
const wsUrl = toValue(url);
wsRef.value = new WebSocket(wsUrl, protocols);
// 追踪所有 WebSocket 实例用于调试和清理(使用模块级变量,避免全局污染)
webSocketList.push(wsRef.value);
wsRef.value.onopen = (event) => {
status.value = 'open';
reconnectCount = 0; // 重置重连计数
startHeartbeat(); // 启动心跳
onOpen?.(event);
};
wsRef.value.onmessage = (event) => {
// 简单的心跳回显处理:如果收到的消息等于发送的心跳消息,则忽略(或者根据业务协议处理 pong)
// 这里假设服务端会原样返回 ping 或者返回特定的 pong
// 实际项目中建议检查 event.data
try {
const data = JSON.parse(event.data);
latestData.value = data;
onMessage?.(data, event);
} catch (e) {
latestData.value = event.data;
onMessage?.(event.data, event);
}
};
wsRef.value.onerror = (event) => {
status.value = 'error';
onError?.(event);
};
wsRef.value.onclose = (event) => {
status.value = 'closed';
stopHeartbeat();
onClose?.(event);
// 触发重连逻辑
handleReconnect();
};
} catch (err) {
console.error(err);
status.value = 'error';
onError?.(err as Event);
handleReconnect();
}
};
// 处理重连(指数退避策略)
const handleReconnect = () => {
// 如果是用户手动调用的 disconnect,不应该重连(可以通过标记位控制,这里简化处理:只要没达到限制就重连)
// 如果需要彻底断开,请调用 disconnect()
if (reconnectLimit !== -1 && reconnectCount >= reconnectLimit) {
return;
}
// 计算延迟时间:线性增长 1s, 2s, 3s, 4s... (最大 10s)
const delay = Math.min(10000, reconnectInterval + reconnectCount * reconnectIncrement);
reconnectCount++;
reconnectTimer = setTimeout(() => {
connect();
}, delay);
};
// 发送消息
const send = (data: any) => {
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
const strData = typeof data === 'string' ? data : JSON.stringify(data);
wsRef.value.send(strData);
} else {
console.warn('WebSocket 未连接,无法发送消息');
}
};
// 手动断开(通常意味着不再自动重连,除非再次调用 connect)
const disconnect = () => {
reconnectCount = reconnectLimit; // 设置计数为最大值,阻止后续重连逻辑
clearTimers();
if (wsRef.value) {
// 从模块级列表中移除这个实例
const index = webSocketList.indexOf(wsRef.value);
if (index > -1) {
webSocketList.splice(index, 1);
}
wsRef.value.close();
wsRef.value = null;
}
status.value = 'closed';
};
// --- 监听与副作用 ---
// 监听 URL 变化,如果变了则重新连接
watch(
() => toValue(url),
() => {
if (status.value !== 'closed') {
connect();
}
},
);
// 组件卸载时清理资源
onBeforeUnmount(() => {
disconnect();
});
// 初始化:如果不是手动模式,则自动连接
if (autoConnect) {
connect();
}
return {
status,
latestData,
send,
connect,
disconnect,
};
}
+57
View File
@@ -0,0 +1,57 @@
.container {
min-height: 100vh;
}
.sider {
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08);
}
.logo {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.logoText {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
.header {
background: #fff;
padding: 0 16px;
display: flex;
align-items: center;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
padding: 0 12px;
display: inline-flex;
align-items: center;
&:hover {
color: #1677ff;
}
}
.content {
margin: 16px;
padding: 24px;
background: #fff;
border-radius: 8px;
min-height: 280px;
}
.footer {
text-align: center;
color: rgba(0, 0, 0, 0.45);
}
+71
View File
@@ -0,0 +1,71 @@
import { computed, defineComponent, ref } from 'vue';
import { useRouter, useRoute, RouterView } from 'vue-router';
import { Layout, Menu } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue';
import {
DashboardOutlined,
InfoCircleOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons-vue';
import styles from './BasicLayout.module.less';
const { Header, Sider, Content, Footer } = Layout;
/**
* 基础布局:左侧导航 + 顶部栏 + 内容区 + 页脚
*/
export default defineComponent({
name: 'BasicLayout',
setup() {
const route = useRoute();
const router = useRouter();
const collapsed = ref(false);
const selectedKeys = computed<string[]>(() => [route.path]);
const menuItems: MenuProps['items'] = [
{ key: '/dashboard', icon: () => <DashboardOutlined />, label: '工作台' },
{ key: '/about', icon: () => <InfoCircleOutlined />, label: '关于' },
];
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
router.push(key as string);
};
const toggleCollapsed = () => {
collapsed.value = !collapsed.value;
};
return () => (
<Layout class={styles.container}>
<Sider class={styles.sider} collapsed={collapsed.value} trigger={null} collapsible>
<div class={styles.logo}>
<span class={styles.logoText}>{collapsed.value ? 'CPMS' : 'CPMS 运营平台'}</span>
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys.value}
items={menuItems}
onClick={handleMenuClick}
/>
</Sider>
<Layout>
<Header class={styles.header}>
<span class={styles.trigger} onClick={toggleCollapsed}>
{collapsed.value ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
</Header>
<Content class={styles.content}>
<RouterView />
</Content>
<Footer class={styles.footer}>CPMS ©{new Date().getFullYear()}</Footer>
</Layout>
</Layout>
);
},
});
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import App from './App';
import router from './router';
import './assets/styles/index.less';
const app = createApp(App);
app.use(router);
app.use(Antd);
app.mount('#app');
+20
View File
@@ -0,0 +1,20 @@
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card {
width: 400px;
max-width: 90vw;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
:global(.ant-card-head-title) {
text-align: center;
font-size: 18px;
font-weight: 600;
}
}
+83
View File
@@ -0,0 +1,83 @@
import { defineComponent, reactive, ref } from 'vue';
import { Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
import { auth } from '@/hooks/useAuth';
import styles from './index.module.less';
interface LoginForm {
username: string;
password: string;
}
/**
* 登录页
*/
export default defineComponent({
name: 'LoginPage',
setup() {
const router = useRouter();
const loading = ref(false);
const form = reactive<LoginForm>({
username: 'admin',
password: '123456',
});
const rules = {
username: [{ required: true, message: '请输入用户名' }],
password: [{ required: true, message: '请输入密码' }],
};
const handleSubmit = async () => {
if (!form.username || !form.password) {
message.warning('请输入用户名和密码');
return;
}
loading.value = true;
// TODO: 替换为真实登录接口
// const res = await post<LoginResult>('/login', { ...form });
// auth.login(res.token);
setTimeout(() => {
auth.login('mock_token_' + Date.now());
message.success('登录成功');
loading.value = false;
router.push('/dashboard');
}, 600);
};
return () => (
<div class={styles.container}>
<Card class={styles.card} title="CPMS 运营平台">
<Form model={form} rules={rules} layout="vertical" onFinish={handleSubmit}>
<FormItem name="username">
<Input
v-model={[form.username, 'value']}
placeholder="用户名"
size="large"
v-slots={{ prefix: () => <UserOutlined /> }}
/>
</FormItem>
<FormItem name="password">
<Input
v-model={[form.password, 'value']}
type="password"
placeholder="密码"
size="large"
v-slots={{ prefix: () => <LockOutlined /> }}
/>
</FormItem>
<FormItem>
<Button type="primary" html-type="submit" size="large" block loading={loading.value}>
</Button>
</FormItem>
</Form>
</Card>
</div>
);
},
});
+40
View File
@@ -0,0 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router';
import { routes } from './routes';
import { auth } from '@/hooks/useAuth';
const router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASE_URL || '/'),
routes,
scrollBehavior: () => ({ left: 0, top: 0 }),
});
// 路由守卫:登录鉴权 + 页面标题
router.beforeEach((to, _from, next) => {
const isLoggedIn = auth.isLoggedIn();
// 设置页面标题
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
document.title = to.meta.title ? `${to.meta.title} - ${appTitle}` : appTitle;
// 如果访问的是登录页
if (to.path === '/login') {
if (isLoggedIn) {
// 已登录则重定向到首页
next('/dashboard');
} else {
// 未登录则允许访问登录页
next();
}
} else {
// 访问其他页面
if (isLoggedIn) {
// 已登录则允许访问
next();
} else {
// 未登录则重定向到登录页
next('/login');
}
}
});
export default router;
+68
View File
@@ -0,0 +1,68 @@
import type { RouteRecordRaw } from 'vue-router';
import { createRoute } from './utils';
/**
* ====== 业务路由模块 ======
* 实际项目中按模块拆分,如 eventsRoutes、walletRoutes 等,
* 然后在下方 routes 数组的布局 children 中引入。
* 参考示例(嵌套路由):
*
* const eventsRoutes = {
* path: '/events',
* name: 'EventsModule',
* redirect: '/events/list',
* meta: { title: '我的赛事', activeMenu: '/events' },
* children: [
* createRoute('list', () => import('@/pages/events/list'), { title: '赛事列表' }),
* {
* path: 'bracket',
* name: 'BracketPage',
* component: () => import('@/pages/events/bracket'),
* meta: { title: '赛事详情' },
* children: [
* createRoute('player-management', () => import('@/pages/events/bracket/player-management'), { title: '选手管理' }),
* createRoute('match-result', () => import('@/pages/events/bracket/match-result'), { title: '比赛结果' }),
* ],
* },
* ],
* };
*/
export const routes: RouteRecordRaw[] = [
// ── 登录页(不经过布局)──
{
path: '/login',
name: 'Login',
component: () => import('@/pages/login'),
meta: { title: '登录' },
},
// ── 主布局(需要登录)──
{
path: '/',
component: () => import('@/layouts/BasicLayout'),
children: [
// 业务模块路由在此添加,例如:eventsRoutes, walletRoutes
createRoute('/dashboard', () => import('@/views/Dashboard'), {
title: '工作台',
icon: 'DashboardOutlined',
}),
createRoute('/about', () => import('@/views/About'), {
title: '关于',
icon: 'InfoCircleOutlined',
}),
// 默认重定向
{ path: '/', redirect: '/dashboard' },
],
},
// ── 404 ──
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound'),
meta: { title: '页面不存在' },
},
];
+40
View File
@@ -0,0 +1,40 @@
import type { RouteRecordRaw } from 'vue-router';
/** 路由 meta 类型 */
export type AppMeta = {
title: string;
icon?: string;
hideInMenu?: boolean;
activeMenu?: string;
};
/**
* 快速创建路由记录
* 根据路径自动生成 PascalCase 路由 name
*
* @example
* createRoute('list', () => import('@/pages/home'), { title: '赛事列表' })
* // => { path: 'list', name: 'List', meta: {...}, component: ... }
*/
export function createRoute(
path: string,
component: RouteRecordRaw['component'],
meta: AppMeta,
children: RouteRecordRaw[] = [],
): RouteRecordRaw {
const cleanPath = path.replace(/[:/]/g, '-');
const name = cleanPath
.split('-')
.filter(Boolean)
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join('');
return {
path,
name: name || undefined,
meta,
component,
children,
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 全局类型定义
*/
/** 分页查询参数 */
export interface PageParams {
page: number;
size: number;
}
/** 分页返回结果 */
export interface PageResult<T> {
list: T[];
total: number;
page: number;
size: number;
}
/** 通用下拉选项 */
export interface SelectOption {
label: string;
value: string | number;
disabled?: boolean;
}
/** 路由 meta 扩展(与 router/utils.ts 的 AppMeta 保持一致) */
declare module 'vue-router' {
interface RouteMeta {
title?: string;
icon?: string;
/** 是否需要登录 */
requiresAuth?: boolean;
/** 是否在菜单中隐藏 */
hideInMenu?: boolean;
/** 旧字段,等价于 hideInMenu */
hidden?: boolean;
/** 高亮的菜单路径(用于详情页高亮列表项) */
activeMenu?: string;
/** 权限标识 */
permission?: string;
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* 通用工具函数
*/
/**
* 生成唯一 ID
*/
export function uniqueId(prefix = ''): string {
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
return prefix ? `${prefix}_${id}` : id;
}
/**
* 简易深拷贝(基于 JSON,适用于纯数据对象)
*/
export function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
/**
* 防抖
*/
export function debounce<T extends (...args: any[]) => any>(fn: T, wait = 300) {
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
debounced.cancel = () => {
if (timer) clearTimeout(timer);
timer = null;
};
return debounced;
}
/**
* 节流
*/
export function throttle<T extends (...args: any[]) => any>(fn: T, wait = 300) {
let lastTime = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - lastTime >= wait) {
lastTime = now;
fn(...args);
}
};
}
/**
* 格式化日期
*/
export function formatDate(date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss'): string {
const d = new Date(date);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
const map: Record<string, string> = {
YYYY: String(d.getFullYear()),
MM: pad(d.getMonth() + 1),
DD: pad(d.getDate()),
HH: pad(d.getHours()),
mm: pad(d.getMinutes()),
ss: pad(d.getSeconds()),
};
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => map[match]);
}
/**
* 树形数据扁平化
*/
export interface TreeNode {
id: string | number;
children?: TreeNode[];
[key: string]: any;
}
export function flattenTree<T extends TreeNode>(tree: T[]): T[] {
const result: T[] = [];
const stack = [...tree].reverse();
while (stack.length) {
const node = stack.pop() as T;
result.push(node);
if (node.children?.length) {
stack.push(...(node.children as T[]).reverse());
}
}
return result;
}
+164
View File
@@ -0,0 +1,164 @@
import { auth } from '@/hooks/';
import { message } from 'ant-design-vue';
const baseUrl = import.meta.env?.VITE_API_BASE_URL || '';
const DEFAULT_TIMEOUT = 10000;
const activeControllers = new Set<AbortController>();
/** 统一处理 401 未授权逻辑 */
const handleUnauthorized = (msg?: string) => {
auth.logout();
message.error(msg || '登录状态已过期,请重新登录');
window.location.href = '/login';
};
const request = (
url: string,
options: RequestInit & {
query?: Record<string, any>;
responseType?: 'json' | 'blob';
} = {},
) => {
let finalUrl = baseUrl + url;
if (options.query) {
finalUrl += '?' + new URLSearchParams(options.query).toString();
}
const token = auth.token.value;
const headers: Record<string, string> = {};
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 优先使用外部传入的 signal(来自 useRequest 取消机制);
// 无外部 signal 时自建 controller 用于超时取消
const externalSignal = (options as RequestInit & { signal?: AbortSignal }).signal;
const controller = new AbortController();
activeControllers.add(controller);
// 合并外部 signal 与内部 controller:任一 abort 都会取消请求
const onExternalAbort = () => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
}
}
const init: RequestInit = {
...options,
headers: { ...headers, ...(options.headers as Record<string, string>) },
signal: controller.signal,
};
const timeoutId = setTimeout(() => {
controller.abort(); // 超时触发 abort
}, DEFAULT_TIMEOUT);
const cleanupSignal = () => {
clearTimeout(timeoutId);
if (externalSignal) externalSignal.removeEventListener('abort', onExternalAbort);
};
return fetch(finalUrl, init)
.then(async (res) => {
cleanupSignal();
activeControllers.delete(controller);
if (res.status === 401) {
handleUnauthorized();
return Promise.reject(new Error('Unauthorized'));
}
if (res.status === 500) {
message.error('服务器开小差了,请稍后再试');
const errorData = await res.json().catch(() => ({}));
return Promise.reject(new Error(errorData.message || '服务器内部错误'));
}
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
message.error(errorData.message || '请求失败');
return Promise.reject(new Error(errorData.message || '请求失败'));
}
if (options.responseType === 'blob') {
return res.blob();
}
const data = await res.json();
if (data.code === 401) {
handleUnauthorized(data.msg);
return Promise.reject(new Error(data.msg || 'Unauthorized'));
}
return data;
})
.catch((err) => {
cleanupSignal();
activeControllers.delete(controller);
if (err.name === 'AbortError' || err.message === 'The user aborted a request.') {
// 使用特殊标记对象区分“请求被取消”和“请求成功但返回 null”
return Promise.resolve({ __aborted: true });
}
if (err.message === 'Failed to fetch') {
message.error('网络连接失败,请检查网络');
}
return Promise.reject(err);
});
};
export const cancelAllRequests = () => {
activeControllers.forEach((controller) => {
controller.abort();
});
activeControllers.clear();
};
export { request };
export const get = (url: string, query?: Record<string, any>, options?: { signal?: AbortSignal }) =>
request(url, { method: 'GET', query, signal: options?.signal });
export const post = (
url: string,
body?: any,
query?: Record<string, any>,
options?: { signal?: AbortSignal },
) =>
request(url, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
query,
signal: options?.signal,
});
export const put = (url: string, body?: any, query?: Record<string, any>) =>
request(url, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
query,
});
export const del = (url: string, query?: Record<string, any>) =>
request(url, { method: 'DELETE', query });
export default {
get,
post,
put,
delete: del,
};
+20
View File
@@ -0,0 +1,20 @@
.container {
// 容器
}
.title {
margin-top: 0;
margin-bottom: 24px;
font-size: 20px;
font-weight: 600;
}
.subtitle {
margin: 16px 0 8px;
}
.list {
margin: 0;
padding-left: 20px;
line-height: 2;
}
+42
View File
@@ -0,0 +1,42 @@
import { defineComponent } from 'vue';
import { Card } from 'ant-design-vue';
import styles from './About.module.less';
interface TechItem {
name: string;
version: string;
}
/**
* 关于页面
*/
export default defineComponent({
name: 'About',
setup() {
const techStack: TechItem[] = [
{ name: 'Vue', version: '^3.3.0' },
{ name: 'Vite', version: '^5.0.0' },
{ name: 'TypeScript', version: '^5.3.0' },
{ name: 'Ant Design Vue', version: '^4.0.0' },
{ name: 'Vue Router', version: '^4.2.0' },
{ name: 'Less', version: '^4.6.4' },
];
return () => (
<div class={styles.container}>
<h2 class={styles.title}></h2>
<Card>
<p>CPMS PC </p>
<h3 class={styles.subtitle}></h3>
<ul class={styles.list}>
{techStack.map((item) => (
<li key={item.name}>
{item.name} {item.version}
</li>
))}
</ul>
</Card>
</div>
);
},
});
+27
View File
@@ -0,0 +1,27 @@
.container {
// 容器
}
.title {
margin-top: 0;
margin-bottom: 24px;
font-size: 20px;
font-weight: 600;
}
.panel {
margin-top: 16px;
ul {
margin: 12px 0 0;
padding-left: 20px;
line-height: 2;
}
code {
padding: 2px 8px;
background: #f5f5f5;
border-radius: 4px;
font-size: 13px;
}
}
+67
View File
@@ -0,0 +1,67 @@
import { defineComponent } from 'vue';
import { Card, Col, Row, Statistic } from 'ant-design-vue';
import styles from './Dashboard.module.less';
interface StatItem {
title: string;
value: number;
suffix: string;
precision?: number;
}
/**
* 工作台 / 仪表盘
*/
export default defineComponent({
name: 'Dashboard',
setup() {
const stats: StatItem[] = [
{ title: '今日访问', value: 1280, suffix: '次' },
{ title: '活跃用户', value: 368, suffix: '人' },
{ title: '订单总数', value: 9920, suffix: '单' },
{ title: '处理率', value: 96.8, suffix: '%', precision: 1 },
];
return () => (
<div class={styles.container}>
<h2 class={styles.title}></h2>
<Row gutter={[16, 16]}>
{stats.map((item) => (
<Col key={item.title} xs={24} sm={12} lg={6}>
<Card>
<Statistic
title={item.title}
value={item.value}
suffix={item.suffix}
precision={item.precision}
/>
</Card>
</Col>
))}
</Row>
<Card class={styles.panel} title="项目说明">
<p>
使 CPMS Vue 3 + Vite + TypeScript + Ant Design Vue
, TSX ,
</p>
<ul>
<li>
:<code>npm run dev</code>
</li>
<li>
():<code>npm run build:test</code>
</li>
<li>
():<code>npm run build:prod</code>
</li>
<li>
:<code>npm run lint</code>
</li>
</ul>
</Card>
</div>
);
},
});
+32
View File
@@ -0,0 +1,32 @@
import { defineComponent } from 'vue';
import { useRouter } from 'vue-router';
import { Button, Result } from 'ant-design-vue';
/**
* 404 页面
*/
export default defineComponent({
name: 'NotFound',
setup() {
const router = useRouter();
const goHome = () => router.push('/');
return () => (
<Result
status="404"
title="404"
subTitle="抱歉,你访问的页面不存在。"
style={{ paddingTop: '120px' }}
>
{{
extra: () => (
<Button type="primary" onClick={goHome}>
</Button>
),
}}
</Result>
);
},
});