chore: starter

This commit is contained in:
2026-01-29 18:44:04 +08:00
commit 9cda358a2e
91 changed files with 16946 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
{
"name": "@cslab-dcs/bridge",
"type": "module",
"version": "1.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./tauri": {
"types": "./src/tauri.ts",
"import": "./src/tauri.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.5"
},
"devDependencies": {
"electron": "^33.2.1",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,89 @@
import type {
ConfirmOptions,
FileDialogOptions,
IDialogService,
IFileService,
IPlatformBridge,
IStorageService,
ISystemService,
MessageOptions,
PlatformType,
SaveDialogOptions,
} from '../types'
// Electron IPC 接口定义(需要在 electron preload 中实现)
interface IElectronAPI {
ipcRenderer: {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
send: (channel: string, ...args: unknown[]) => void
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void
removeListener: (channel: string, listener: (...args: unknown[]) => void) => void
}
}
declare global {
interface Window {
electron: IElectronAPI
}
}
export class ElectronBridge implements IPlatformBridge {
readonly platform: PlatformType = 'electron'
file: IFileService = {
async read(path: string): Promise<string> {
return (await window.electron.ipcRenderer.invoke('file:read', path)) as string
},
async write(path: string, content: string): Promise<void> {
await window.electron.ipcRenderer.invoke('file:write', path, content)
},
async exists(path: string): Promise<boolean> {
return (await window.electron.ipcRenderer.invoke('file:exists', path)) as boolean
},
async openDialog(options?: FileDialogOptions): Promise<string | string[] | null> {
return (await window.electron.ipcRenderer.invoke('dialog:open', options)) as string | string[] | null
},
async saveDialog(options?: SaveDialogOptions): Promise<string | null> {
return (await window.electron.ipcRenderer.invoke('dialog:save', options)) as string | null
},
}
dialog: IDialogService = {
async message(options: MessageOptions): Promise<void> {
await window.electron.ipcRenderer.invoke('dialog:message', options)
},
async confirm(options: ConfirmOptions): Promise<boolean> {
return (await window.electron.ipcRenderer.invoke('dialog:confirm', options)) as boolean
},
}
storage: IStorageService = {
async get<T>(key: string): Promise<T | null> {
// Electron 环境也可以复用 localStorage或者使用 electron-store
// 这里为了简单,先使用 localStorage生产环境建议走 IPC 存本地文件
const val = localStorage.getItem(key)
return val ? JSON.parse(val) : null
},
async set<T>(key: string, value: T): Promise<void> {
localStorage.setItem(key, JSON.stringify(value))
},
async remove(key: string): Promise<void> {
localStorage.removeItem(key)
},
async clear(): Promise<void> {
localStorage.clear()
},
}
system: ISystemService = {
async getAppVersion(): Promise<string> {
return (await window.electron.ipcRenderer.invoke('app:version')) as string
},
async getPlatformInfo() {
return (await window.electron.ipcRenderer.invoke('app:info')) as any
},
async openExternal(url: string): Promise<void> {
await window.electron.ipcRenderer.invoke('shell:open', url)
},
}
}

View File

@@ -0,0 +1,103 @@
import type {
ConfirmOptions,
FileDialogOptions,
IDialogService,
IFileService,
IPlatformBridge,
IStorageService,
ISystemService,
MessageOptions,
PlatformType,
SaveDialogOptions,
} from '../types'
import { getTauriVersion, getVersion } from '@tauri-apps/api/app'
import { ask, message, open, save } from '@tauri-apps/plugin-dialog'
import { exists, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { openUrl } from '@tauri-apps/plugin-opener'
import { arch, platform } from '@tauri-apps/plugin-os'
export class TauriBridge implements IPlatformBridge {
readonly platform: PlatformType = 'tauri'
file: IFileService = {
async read(path: string): Promise<string> {
return await readTextFile(path)
},
async write(path: string, content: string): Promise<void> {
await writeTextFile(path, content)
},
async exists(path: string): Promise<boolean> {
return await exists(path)
},
async openDialog(options?: FileDialogOptions): Promise<string | string[] | null> {
const selected = await open({
multiple: options?.multiple ?? false,
directory: options?.directory ?? false,
filters: options?.filters,
defaultPath: options?.defaultPath,
})
if (selected === null)
return null
// 这里的类型转换取决于 multiple 选项
return selected as string | string[]
},
async saveDialog(options?: SaveDialogOptions): Promise<string | null> {
return await save({
defaultPath: options?.defaultPath,
filters: options?.filters,
})
},
}
dialog: IDialogService = {
async message(options: MessageOptions): Promise<void> {
await message(options.message, {
title: options.title,
kind: options.type === 'error' ? 'error' : options.type === 'warning' ? 'warning' : 'info',
})
},
async confirm(options: ConfirmOptions): Promise<boolean> {
return await ask(options.message, {
title: options.title,
kind: options.type === 'error' ? 'error' : options.type === 'warning' ? 'warning' : 'info',
okLabel: options.okLabel || 'Yes',
cancelLabel: options.cancelLabel || 'No',
})
},
}
storage: IStorageService = {
async get<T>(key: string): Promise<T | null> {
// Tauri 也可以用 tauri-plugin-store这里简化用 localStorage
const val = localStorage.getItem(key)
return val ? JSON.parse(val) : null
},
async set<T>(key: string, value: T): Promise<void> {
localStorage.setItem(key, JSON.stringify(value))
},
async remove(key: string): Promise<void> {
localStorage.removeItem(key)
},
async clear(): Promise<void> {
localStorage.clear()
},
}
system: ISystemService = {
async getAppVersion(): Promise<string> {
return await getVersion()
},
async getPlatformInfo() {
return {
name: 'tauri',
version: await getTauriVersion(),
os: await platform(),
arch: await arch(),
}
},
async openExternal(url: string): Promise<void> {
await openUrl(url)
},
}
}

View File

@@ -0,0 +1,95 @@
import type {
ConfirmOptions,
FileDialogOptions,
IDialogService,
IFileService,
IPlatformBridge,
IStorageService,
ISystemService,
MessageOptions,
PlatformType,
SaveDialogOptions,
} from '../types'
export class WebBridge implements IPlatformBridge {
readonly platform: PlatformType = 'web'
file: IFileService = {
async read(_path: string): Promise<string> {
console.warn('Web environment does not support file system access directly.')
return ''
},
async write(_path: string, _content: string): Promise<void> {
console.warn('Web environment does not support file system access directly.')
},
async exists(_path: string): Promise<boolean> {
return false
},
async openDialog(_options?: FileDialogOptions): Promise<string | string[] | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
// Web端模拟仅用于演示
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files
if (files && files.length > 0) {
// Web端通常不能获取完整路径这里只是模拟
resolve(files[0].name)
}
else {
resolve(null)
}
}
input.click()
})
},
async saveDialog(_options?: SaveDialogOptions): Promise<string | null> {
console.warn('Web environment save dialog not fully supported.')
return null
},
}
dialog: IDialogService = {
async message(options: MessageOptions): Promise<void> {
// eslint-disable-next-line no-alert
alert(`${options.title ? `${options.title}\n` : ''}${options.message}`)
},
async confirm(options: ConfirmOptions): Promise<boolean> {
// eslint-disable-next-line no-alert
return confirm(`${options.title ? `${options.title}\n` : ''}${options.message}`)
},
}
storage: IStorageService = {
async get<T>(key: string): Promise<T | null> {
const val = localStorage.getItem(key)
return val ? JSON.parse(val) : null
},
async set<T>(key: string, value: T): Promise<void> {
localStorage.setItem(key, JSON.stringify(value))
},
async remove(key: string): Promise<void> {
localStorage.removeItem(key)
},
async clear(): Promise<void> {
localStorage.clear()
},
}
system: ISystemService = {
async getAppVersion(): Promise<string> {
return '1.0.0'
},
async getPlatformInfo() {
return {
name: 'web',
version: navigator.userAgent,
os: navigator.platform,
arch: 'unknown',
}
},
async openExternal(url: string): Promise<void> {
window.open(url, '_blank')
},
}
}

View File

@@ -0,0 +1,23 @@
import type { IPlatformBridge } from './types'
import { ElectronBridge } from './adapters/electron'
import { WebBridge } from './adapters/web'
export { ElectronBridge } from './adapters/electron'
export { WebBridge } from './adapters/web'
export * from './types'
export function createBridge(): IPlatformBridge {
if (typeof window !== 'undefined') {
const win = window as any
if (win.electron && win.electron.ipcRenderer) {
return new ElectronBridge()
}
// Tauri 检测逻辑 - 注意:这里不能引用 TauriBridge否则会破坏 Electron 构建
// Tauri App 必须手动初始化 Bridge 并通过 provide 注入,或者我们依赖 userAgent 判断
// 但 instantiation 必须在 app 层做。
}
return new WebBridge()
}
export const bridge = createBridge()

View File

@@ -0,0 +1 @@
export * from './adapters/tauri'

View File

@@ -0,0 +1,77 @@
/**
* 平台桥接类型定义
*/
export type PlatformType = 'web' | 'electron' | 'tauri'
export interface FileDialogOptions {
title?: string
defaultPath?: string
filters?: {
name: string
extensions: string[]
}[]
multiple?: boolean
directory?: boolean
}
export interface SaveDialogOptions {
title?: string
defaultPath?: string
filters?: {
name: string
extensions: string[]
}[]
}
export interface MessageOptions {
title?: string
message: string
type?: 'info' | 'warning' | 'error'
}
export interface ConfirmOptions extends MessageOptions {
okLabel?: string
cancelLabel?: string
}
export interface PlatformInfo {
name: PlatformType
version: string
os: string
arch: string
}
export interface IFileService {
read: (path: string) => Promise<string>
write: (path: string, content: string) => Promise<void>
exists: (path: string) => Promise<boolean>
openDialog: (options?: FileDialogOptions) => Promise<string | string[] | null>
saveDialog: (options?: SaveDialogOptions) => Promise<string | null>
}
export interface IDialogService {
message: (options: MessageOptions) => Promise<void>
confirm: (options: ConfirmOptions) => Promise<boolean>
}
export interface IStorageService {
get: <T>(key: string) => Promise<T | null>
set: <T>(key: string, value: T) => Promise<void>
remove: (key: string) => Promise<void>
clear: () => Promise<void>
}
export interface ISystemService {
getAppVersion: () => Promise<string>
getPlatformInfo: () => Promise<PlatformInfo>
openExternal: (url: string) => Promise<void>
}
export interface IPlatformBridge {
readonly platform: PlatformType
file: IFileService
dialog: IDialogService
storage: IStorageService
system: ISystemService
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"types": ["electron", "node"],
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

13
packages/core/env.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
/// <reference types="element-plus/global" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
interface Window {
// 可以扩展 window 类型
}

View File

@@ -0,0 +1,46 @@
{
"name": "@cslab-dcs/core",
"type": "module",
"version": "1.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./vite.shared": {
"types": "./vite.shared.d.ts",
"import": "./vite.shared.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@cslab-dcs/bridge": "workspace:*",
"@cslab-dcs/schema": "workspace:*",
"@cslab-dcs/utils": "workspace:*",
"@vueuse/core": "^12.0.0",
"dayjs": "^1.11.13",
"element-plus": "^2.9.0",
"mitt": "^3.0.1",
"ofetch": "^1.4.1",
"pinia": "^3.0.0",
"pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"sass": "^1.83.0",
"typescript": "^5.9.3",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"vite": "^6.0.3",
"vue-tsc": "^2.2.0"
}
}

24
packages/core/src/App.vue Normal file
View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const locale = zhCn
</script>
<template>
<ElConfigProvider :locale="locale">
<router-view />
</ElConfigProvider>
</template>
<style>
/* 全局样式 */
html, body, #app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
}
</style>

View File

@@ -0,0 +1,26 @@
@use './variables.scss' as *;
/* Reset or Base styles */
body {
background-color: $bg-color-page;
color: $text-color-primary;
}
/* Scrollbar customization */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: $border-color;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: $text-color-secondary;
}

View File

@@ -0,0 +1,18 @@
/* Global SCSS Variables */
$primary-color: #409eff;
$success-color: #67c23a;
$warning-color: #e6a23c;
$danger-color: #f56c6c;
$info-color: #909399;
$text-color-primary: #303133;
$text-color-regular: #606266;
$text-color-secondary: #909399;
$text-color-placeholder: #a8abb2;
$border-color: #dcdfe6;
$border-color-light: #e4e7ed;
$border-color-lighter: #ebeef5;
$bg-color: #ffffff;
$bg-color-page: #f2f3f5;

View File

@@ -0,0 +1,38 @@
import type { IPlatformBridge } from '@cslab-dcs/bridge'
import type { App } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
/**
* 核心包入口
*/
import { createApp } from 'vue'
import AppRoot from './App.vue'
import router from './router'
import './assets/styles/index.scss'
export function createDCSApp(bridge?: IPlatformBridge): App {
const app = createApp(AppRoot)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
// 如果没有传入 bridge尝试自动创建但这可能无法涵盖 Tauri
// 实际上,为了避免自动创建时的依赖问题,我们建议 Apps 必须传入 bridge
// 或者我们在 @cslab-dcs/core 中不直接依赖 @cslab-dcs/bridge 的实现,只依赖类型
// 但这里为了方便,我们只对 globalProperties 做注入
if (bridge) {
app.config.globalProperties.$bridge = bridge
app.provide('bridge', bridge)
}
return app
}
export { default as AppRoot } from './App.vue'
export * from './router'
export * from './stores'

View File

@@ -0,0 +1,19 @@
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
meta: { title: '首页' },
},
// 可以添加更多路由
]
const router = createRouter({
history: createWebHashHistory(), // 使用 Hash 模式兼容 Electron
routes,
})
export default router

View File

@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const theme = ref<'light' | 'dark'>('light')
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
return {
theme,
toggleTheme,
}
}, {
persist: true,
})

View File

@@ -0,0 +1,8 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export * from './app'
export default pinia

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { IPlatformBridge } from '@cslab-dcs/bridge'
import { bridge as defaultBridge } from '@cslab-dcs/bridge'
import { inject, onMounted, ref } from 'vue'
const platformInfo = ref<any>(null)
const appVersion = ref('')
const bridge = inject<IPlatformBridge>('bridge', defaultBridge)
onMounted(async () => {
platformInfo.value = await bridge.system.getPlatformInfo()
appVersion.value = await bridge.system.getAppVersion()
})
async function handleOpenFile() {
const result = await bridge.file.openDialog({
title: '打开配置',
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (result) {
await bridge.dialog.message({
title: '选择文件',
message: `你选择了: ${Array.isArray(result) ? result.join(', ') : result}`,
})
}
}
function handleGreet() {
bridge.dialog.message({
title: 'Hello',
message: `Welcome to DCS Editor on ${platformInfo.value?.name}`,
})
}
</script>
<template>
<div class="home-container">
<div class="content">
<h1>DCS Editor ({{ platformInfo?.name || 'Loading...' }})</h1>
<p>Version: {{ appVersion }}</p>
<div class="card">
<el-button type="primary" @click="handleGreet">
打招呼
</el-button>
<el-button type="success" @click="handleOpenFile">
打开文件
</el-button>
</div>
<div class="debug-info">
<h3>Platform Info:</h3>
<pre>{{ JSON.stringify(platformInfo, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.home-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
.content {
text-align: center;
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
.card {
margin: 20px 0;
gap: 10px;
display: flex;
justify-content: center;
}
.debug-info {
margin-top: 20px;
text-align: left;
background: #f8f9fa;
padding: 10px;
border-radius: 4px;
pre {
margin: 0;
font-size: 12px;
}
}
}
}
</style>

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@cslab-dcs/bridge": ["../bridge/src"],
"@cslab-dcs/schema": ["../schema/src"],
"@cslab-dcs/utils": ["../utils/src"]
},
"types": ["element-plus/global"]
},
"include": ["src/**/*", "vite.shared.ts", "env.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,43 @@
import type { UserConfig } from 'vite'
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
export function createSharedViteConfig(rootDir: string): UserConfig {
return {
plugins: [
vue(),
vueJsx(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia', '@vueuse/core'],
resolvers: [ElementPlusResolver()],
dts: resolve(rootDir, 'src/auto-imports.d.ts'),
eslintrc: {
enabled: true,
},
}),
Components({
resolvers: [ElementPlusResolver()],
dts: resolve(rootDir, 'src/components.d.ts'),
}),
],
resolve: {
alias: {
'@': resolve(rootDir, 'src'),
'@cslab-dcs/core': resolve(__dirname, './src'),
},
},
css: {
preprocessorOptions: {
scss: {
// 自定义 element-plus 主题或全局变量
additionalData: `@use "@/assets/styles/variables.scss" as *;`,
api: 'modern-compiler', // sass-loader v15+ requirement
},
},
},
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "@cslab-dcs/schema",
"type": "module",
"version": "1.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./*": {
"types": "./src/*.ts",
"import": "./src/*.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,99 @@
/**
* DCS 配置相关类型和验证器
*/
import { z } from 'zod'
/**
* DCS 配置项类型
*/
export type DCSValueType = 'string' | 'number' | 'boolean' | 'array' | 'object'
/**
* DCS 配置项
*/
export interface DCSConfigItem {
key: string
value: unknown
type: DCSValueType
description?: string
required?: boolean
children?: DCSConfigItem[]
}
/**
* DCS 配置文件
*/
export interface DCSConfigFile {
name: string
path: string
version: string
description?: string
items: DCSConfigItem[]
createdAt: string
updatedAt: string
}
/**
* DCS 项目
*/
export interface DCSProject {
id: string
name: string
description?: string
configs: DCSConfigFile[]
createdAt: string
updatedAt: string
}
/**
* DCS 配置项验证器
*/
export const dcsConfigItemSchema: z.ZodType<DCSConfigItem> = z.lazy(() =>
z.object({
key: z.string().min(1, '配置键不能为空'),
value: z.unknown(),
type: z.enum(['string', 'number', 'boolean', 'array', 'object']),
description: z.string().optional(),
required: z.boolean().optional(),
children: z.array(dcsConfigItemSchema).optional(),
}),
)
/**
* DCS 配置文件验证器
*/
export const dcsConfigFileSchema = z.object({
name: z.string().min(1, '文件名不能为空'),
path: z.string().min(1, '文件路径不能为空'),
version: z.string().regex(/^\d+\.\d+\.\d+$/, '版本号格式应为 x.x.x'),
description: z.string().optional(),
items: z.array(dcsConfigItemSchema),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
/**
* DCS 项目验证器
*/
export const dcsProjectSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1, '项目名不能为空'),
description: z.string().optional(),
configs: z.array(dcsConfigFileSchema),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
/**
* 验证 DCS 配置
*/
export function validateDCSConfig(data: unknown): DCSConfigFile {
return dcsConfigFileSchema.parse(data)
}
/**
* 安全验证 DCS 配置
*/
export function safeParseDCSConfig(data: unknown) {
return dcsConfigFileSchema.safeParse(data)
}

View File

@@ -0,0 +1,8 @@
/**
* @cslab-dcs/schema
* 数据模型和验证器统一导出
*/
export * from './dcs'
export * from './types'
export * from './validators'

View File

@@ -0,0 +1,82 @@
/**
* 通用类型定义
*/
/**
* API 响应结构
*/
export interface ApiResponse<T = unknown> {
code: number
message: string
data: T
success: boolean
}
/**
* 分页请求参数
*/
export interface PaginationParams {
page: number
pageSize: number
}
/**
* 分页响应结构
*/
export interface PaginatedData<T> {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/**
* 树形结构节点
*/
export interface TreeNode<T = unknown> {
id: string
label: string
children?: TreeNode<T>[]
data?: T
disabled?: boolean
isLeaf?: boolean
}
/**
* 键值对
*/
export interface KeyValue<T = string> {
key: string
value: T
}
/**
* 选项结构
*/
export interface SelectOption<T = string | number> {
label: string
value: T
disabled?: boolean
}
/**
* 用户信息
*/
export interface UserInfo {
id: string
username: string
nickname?: string
avatar?: string
email?: string
roles?: string[]
}
/**
* 应用状态
*/
export interface AppState {
theme: 'light' | 'dark' | 'system'
language: string
sidebarCollapsed: boolean
}

View File

@@ -0,0 +1,58 @@
/**
* Zod 验证器
*/
import { z } from 'zod'
/**
* 通用验证规则
*/
export const requiredString = z.string().min(1, '此字段为必填项')
export const optionalString = z.string().optional()
export const email = z.string().email('请输入有效的邮箱地址')
export const phone = z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号')
export const url = z.string().url('请输入有效的 URL')
export const positiveNumber = z.number().positive('请输入正数')
export const nonNegativeNumber = z.number().nonnegative('请输入非负数')
/**
* API 响应验证
*/
export const apiResponseSchema = z.object({
code: z.number(),
message: z.string(),
data: z.unknown(),
success: z.boolean(),
})
/**
* 分页参数验证
*/
export const paginationSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
})
/**
* 用户信息验证
*/
export const userInfoSchema = z.object({
id: z.string(),
username: z.string().min(2, '用户名至少2个字符'),
nickname: z.string().optional(),
avatar: z.string().url().optional(),
email: z.string().email().optional(),
roles: z.array(z.string()).optional(),
})
/**
* 创建可选字段的 schema
*/
export function createPartialSchema<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
return schema.partial()
}
/**
* 重新导出 zod
*/
export { z }
export type { ZodError, ZodSchema, ZodType } from 'zod'

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,29 @@
{
"name": "@cslab-dcs/utils",
"type": "module",
"version": "1.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./*": {
"types": "./src/*.ts",
"import": "./src/*.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dayjs": "^1.11.13",
"mitt": "^3.0.1",
"ofetch": "^1.4.1"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

123
packages/utils/src/date.ts Normal file
View File

@@ -0,0 +1,123 @@
import type { ConfigType, Dayjs, ManipulateType, OpUnitType } from 'dayjs'
/**
* 日期时间工具
* 基于 dayjs 封装
*/
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import 'dayjs/locale/zh-cn'
// 扩展插件
dayjs.extend(relativeTime)
dayjs.extend(duration)
dayjs.extend(utc)
dayjs.extend(timezone)
// 设置中文
dayjs.locale('zh-cn')
/**
* 默认日期格式
*/
export const DATE_FORMAT = 'YYYY-MM-DD'
export const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
export const TIME_FORMAT = 'HH:mm:ss'
/**
* 格式化日期
*/
export function formatDate(date?: ConfigType, format = DATE_FORMAT): string {
return dayjs(date).format(format)
}
/**
* 格式化日期时间
*/
export function formatDateTime(date?: ConfigType, format = DATETIME_FORMAT): string {
return dayjs(date).format(format)
}
/**
* 格式化时间
*/
export function formatTime(date?: ConfigType, format = TIME_FORMAT): string {
return dayjs(date).format(format)
}
/**
* 获取相对时间3分钟前
*/
export function fromNow(date: ConfigType): string {
return dayjs(date).fromNow()
}
/**
* 获取到现在的相对时间
*/
export function toNow(date: ConfigType): string {
return dayjs(date).toNow()
}
/**
* 日期加减
*/
export function addTime(date: ConfigType, value: number, unit: ManipulateType): Dayjs {
return dayjs(date).add(value, unit)
}
/**
* 日期减
*/
export function subtractTime(date: ConfigType, value: number, unit: ManipulateType): Dayjs {
return dayjs(date).subtract(value, unit)
}
/**
* 获取两个日期之间的差值
*/
export function diff(date1: ConfigType, date2: ConfigType, unit: OpUnitType = 'day'): number {
return dayjs(date1).diff(dayjs(date2), unit)
}
/**
* 判断日期是否在某个日期之前
*/
export function isBefore(date1: ConfigType, date2: ConfigType): boolean {
return dayjs(date1).isBefore(dayjs(date2))
}
/**
* 判断日期是否在某个日期之后
*/
export function isAfter(date1: ConfigType, date2: ConfigType): boolean {
return dayjs(date1).isAfter(dayjs(date2))
}
/**
* 判断两个日期是否相同
*/
export function isSame(date1: ConfigType, date2: ConfigType, unit: OpUnitType = 'day'): boolean {
return dayjs(date1).isSame(dayjs(date2), unit)
}
/**
* 获取当前时间戳
*/
export function now(): number {
return dayjs().valueOf()
}
/**
* 解析日期
*/
export function parseDate(date: ConfigType): Dayjs {
return dayjs(date)
}
/**
* 导出 dayjs 实例供高级用法
*/
export { dayjs }

View File

@@ -0,0 +1,84 @@
import type { Emitter, EventType, Handler } from 'mitt'
/**
* 事件总线
* 基于 mitt 封装
*/
import mitt from 'mitt'
// 定义事件类型
export interface AppEvents {
// 通用事件
'app:ready': void
'app:error': Error
// 主题切换
'theme:change': 'light' | 'dark'
// 用户事件
'user:login': { userId: string, token: string }
'user:logout': void
// 文件事件
'file:open': { path: string }
'file:save': { path: string, content: string }
'file:close': { path: string }
// 编辑器事件
'editor:change': { content: string }
'editor:save': void
// 允许自定义事件
[key: string]: unknown
}
// 创建事件总线实例
const emitter: Emitter<AppEvents> = mitt<AppEvents>()
/**
* 发送事件
*/
export function emit<K extends keyof AppEvents>(type: K, event: AppEvents[K]): void {
emitter.emit(type, event)
}
/**
* 监听事件
*/
export function on<K extends keyof AppEvents>(type: K, handler: Handler<AppEvents[K]>): void {
emitter.on(type, handler as Handler<AppEvents[keyof AppEvents]>)
}
/**
* 取消监听事件
*/
export function off<K extends keyof AppEvents>(type: K, handler?: Handler<AppEvents[K]>): void {
emitter.off(type, handler as Handler<AppEvents[keyof AppEvents]>)
}
/**
* 监听所有事件
*/
export function onAll(handler: (type: EventType, event: unknown) => void): void {
emitter.on('*', handler)
}
/**
* 清除所有事件监听
*/
export function clear(): void {
emitter.all.clear()
}
/**
* 事件总线实例
*/
export const eventBus = {
emit,
on,
off,
onAll,
clear,
emitter,
}
export default eventBus

View File

@@ -0,0 +1,195 @@
/**
* 通用辅助函数
*/
/**
* 延迟执行
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* 防抖函数
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number,
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
return function (this: unknown, ...args: Parameters<T>) {
if (timer)
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
/**
* 节流函数
*/
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number,
): (...args: Parameters<T>) => void {
let lastTime = 0
return function (this: unknown, ...args: Parameters<T>) {
const now = Date.now()
if (now - lastTime >= delay) {
lastTime = now
fn.apply(this, args)
}
}
}
/**
* 深拷贝
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T
}
if (Array.isArray(obj)) {
return obj.map(item => deepClone(item)) as T
}
if (obj instanceof Object) {
const copy = {} as T
Object.keys(obj).forEach((key) => {
;(copy as Record<string, unknown>)[key] = deepClone((obj as Record<string, unknown>)[key])
})
return copy
}
return obj
}
/**
* 生成唯一ID
*/
export function generateId(prefix = ''): string {
const timestamp = Date.now().toString(36)
const randomStr = Math.random().toString(36).substring(2, 10)
return `${prefix}${timestamp}${randomStr}`
}
/**
* 生成UUID
*/
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
/**
* 判断是否为空值null、undefined、空字符串、空数组、空对象
*/
export function isEmpty(value: unknown): boolean {
if (value === null || value === undefined)
return true
if (typeof value === 'string' && value.trim() === '')
return true
if (Array.isArray(value) && value.length === 0)
return true
if (typeof value === 'object' && Object.keys(value).length === 0)
return true
return false
}
/**
* 安全解析 JSON
*/
export function safeJsonParse<T>(str: string, defaultValue: T): T {
try {
return JSON.parse(str) as T
}
catch {
return defaultValue
}
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0)
return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${units[i]}`
}
/**
* 驼峰转短横线
*/
export function camelToKebab(str: string): string {
return str.replace(/([A-Z])/g, '-$1').toLowerCase()
}
/**
* 短横线转驼峰
*/
export function kebabToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
}
/**
* 首字母大写
*/
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
/**
* 复制到剪贴板
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
}
catch {
// 降级方案
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const result = document.execCommand('copy')
document.body.removeChild(textarea)
return result
}
}
/**
* 下载文件
*/
export function downloadFile(content: string | Blob, filename: string, mimeType = 'text/plain'): void {
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}

101
packages/utils/src/http.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { FetchOptions } from 'ofetch'
/**
* HTTP 请求工具
* 基于 ofetch 封装
*/
import { ofetch } from 'ofetch'
export interface RequestConfig extends FetchOptions {
baseURL?: string
showError?: boolean
}
const defaultConfig: RequestConfig = {
baseURL: '',
timeout: 30000,
showError: true,
}
/**
* 创建请求实例
*/
export function createRequest(config: RequestConfig = {}) {
const mergedConfig = { ...defaultConfig, ...config }
const request = ofetch.create({
baseURL: mergedConfig.baseURL,
timeout: mergedConfig.timeout,
onRequest({ options }) {
// 请求拦截器 - 添加通用 headers
options.headers = {
...options.headers,
'Content-Type': 'application/json',
}
},
onRequestError({ error }) {
// 请求错误处理
console.error('[Request Error]', error)
},
onResponse({ response }) {
// 响应拦截器
return response._data
},
onResponseError({ response }) {
// 响应错误处理
const status = response.status
const message = response._data?.message || `HTTP Error: ${status}`
console.error('[Response Error]', message)
throw new Error(message)
},
})
return request
}
/**
* 默认请求实例
*/
export const http = createRequest()
/**
* GET 请求
*/
export async function get<T>(url: string, params?: Record<string, unknown>, config?: RequestConfig): Promise<T> {
return http<T>(url, {
method: 'GET',
query: params,
...config,
})
}
/**
* POST 请求
*/
export async function post<T>(url: string, data?: unknown, config?: RequestConfig): Promise<T> {
return http<T>(url, {
method: 'POST',
body: data,
...config,
})
}
/**
* PUT 请求
*/
export async function put<T>(url: string, data?: unknown, config?: RequestConfig): Promise<T> {
return http<T>(url, {
method: 'PUT',
body: data,
...config,
})
}
/**
* DELETE 请求
*/
export async function del<T>(url: string, config?: RequestConfig): Promise<T> {
return http<T>(url, {
method: 'DELETE',
...config,
})
}

View File

@@ -0,0 +1,10 @@
/**
* @cslab-dcs/utils
* 工具函数统一导出
*/
export * from './date'
export * from './event'
export * from './helpers'
export * from './http'
export * from './storage'

View File

@@ -0,0 +1,136 @@
/**
* 本地存储工具
* 抽象 localStorage 操作
*/
const PREFIX = 'cslab-dcs:'
/**
* 存储选项
*/
export interface StorageOptions {
prefix?: string
expire?: number // 过期时间(毫秒)
}
/**
* 存储数据结构
*/
interface StorageData<T> {
value: T
expire?: number
createTime: number
}
/**
* 设置存储
*/
export function setStorage<T>(key: string, value: T, options: StorageOptions = {}): void {
const { prefix = PREFIX, expire } = options
const data: StorageData<T> = {
value,
createTime: Date.now(),
}
if (expire) {
data.expire = Date.now() + expire
}
try {
localStorage.setItem(prefix + key, JSON.stringify(data))
}
catch (e) {
console.error('[Storage] setStorage error:', e)
}
}
/**
* 获取存储
*/
export function getStorage<T>(key: string, options: StorageOptions = {}): T | null {
const { prefix = PREFIX } = options
try {
const raw = localStorage.getItem(prefix + key)
if (!raw)
return null
const data: StorageData<T> = JSON.parse(raw)
// 检查是否过期
if (data.expire && data.expire < Date.now()) {
removeStorage(key, options)
return null
}
return data.value
}
catch (e) {
console.error('[Storage] getStorage error:', e)
return null
}
}
/**
* 移除存储
*/
export function removeStorage(key: string, options: StorageOptions = {}): void {
const { prefix = PREFIX } = options
localStorage.removeItem(prefix + key)
}
/**
* 清空所有存储
*/
export function clearStorage(options: StorageOptions = {}): void {
const { prefix = PREFIX } = options
const keys = Object.keys(localStorage).filter(k => k.startsWith(prefix))
keys.forEach(key => localStorage.removeItem(key))
}
/**
* 获取所有存储键
*/
export function getStorageKeys(options: StorageOptions = {}): string[] {
const { prefix = PREFIX } = options
return Object.keys(localStorage)
.filter(k => k.startsWith(prefix))
.map(k => k.replace(prefix, ''))
}
/**
* Session 存储
*/
export const sessionStorage = {
set<T>(key: string, value: T): void {
try {
window.sessionStorage.setItem(PREFIX + key, JSON.stringify(value))
}
catch (e) {
console.error('[SessionStorage] set error:', e)
}
},
get<T>(key: string): T | null {
try {
const raw = window.sessionStorage.getItem(PREFIX + key)
return raw ? JSON.parse(raw) : null
}
catch (e) {
console.error('[SessionStorage] get error:', e)
return null
}
},
remove(key: string): void {
window.sessionStorage.removeItem(PREFIX + key)
},
clear(): void {
const keys = Object.keys(window.sessionStorage).filter(k => k.startsWith(PREFIX))
keys.forEach(key => window.sessionStorage.removeItem(key))
},
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}