feat: reoede web to tailwindcss

This commit is contained in:
engigu
2025-08-10 14:32:24 +08:00
parent 0a12a25473
commit d557547bc0
192 changed files with 9624 additions and 9694 deletions
-14
View File
@@ -1,14 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
View File
+10 -26
View File
@@ -1,29 +1,13 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Message Nest</title>
</head>
<style>
body {
margin: 0;
padding: 0;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
</style>
<body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2443 -5751
View File
File diff suppressed because it is too large Load Diff
+28 -27
View File
@@ -1,39 +1,40 @@
{
"name": "message-nest",
"version": "1.0.0",
"name": "message-nest-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"demo": "vite --mode demo",
"build:demo": "vite build --mode demo",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.7.4",
"cron-parser": "^4.9.0",
"echarts": "^5.4.3",
"element-plus": "^2.4.2",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"prismjs": "^1.29.0",
"uuid": "^9.0.1",
"vue": "^3.3.4",
"vue-clipboard3": "^2.0.0",
"vue-router": "^4.2.5"
"@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^13.6.0",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.536.0",
"pinia": "^3.0.3",
"reka-ui": "^2.4.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"vaul-vue": "^0.4.1",
"vue": "^3.5.17",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.2",
"vue3-apexcharts": "^1.8.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/eslint-config-prettier": "^8.0.0",
"cross-env": "^7.0.3",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"prettier": "^3.0.3",
"vite": "^4.5.3",
"vite-plugin-prismjs": "^0.0.8"
"@types/node": "^24.2.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vue-tsc": "^2.2.12"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+14 -7
View File
@@ -1,9 +1,16 @@
<template>
<Index></Index>
</template>
<script setup>
import Index from "./views/home/index.vue"
<script lang="ts">
export const description = "A login page with form and image."
</script>
<style></style>
<script setup lang="ts">
import { Toaster } from '@/components/ui/sonner'
import 'vue-sonner/style.css'
// 不再直接导入Index组件,而是使用路由视图
</script>
<template>
<!-- shacn-vue的toast -->
<Toaster />
<!-- 使用路由视图代替直接渲染Index组件 -->
<router-view />
</template>
+11
View File
@@ -0,0 +1,11 @@
import { AxiosInstance, AxiosResponse } from 'axios';
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
export declare const request: AxiosInstance;
export declare function handleException(error: any): void;
export declare function logout(): void;
+20 -9
View File
@@ -1,9 +1,11 @@
// request.js
import axios from 'axios';
import { ElMessage } from 'element-plus'
import { usePageState } from '../store/page_sate';
import { CONSTANT } from '../constant';
import config from '../../config.js';
import { toast } from "vue-sonner"
const ERR_NETWORK = "ERR_NETWORK";
@@ -14,6 +16,7 @@ const request = axios.create({
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
@@ -27,7 +30,6 @@ request.interceptors.request.use(
'm-token': pageState.Token,
};
}
return config;
},
(error) => {
@@ -38,16 +40,20 @@ request.interceptors.request.use(
// 响应拦截器
request.interceptors.response.use(
(response) => {
if (response && response.data.code != 200) {
ElMessage({ message: response.data.msg, type: 'error' })
toast.error(response.data.msg, {
description: '接口逻辑错误'
})
// Promise.reject();
}
return response;
},
(error) => {
if (error.response && error.response.status == 401) {
logout();
} else if (20000 <= error.response.status && error.response.status <= 29999) {
} else if (error.response && 20000 <= error.response.status && error.response.status <= 29999) {
logout();
} else {
handleException(error);
@@ -57,11 +63,16 @@ request.interceptors.response.use(
// 异常处理
const handleException = (error) => {
if (!error.response) {
return
};
if (error.code == ERR_NETWORK) {
ElMessage({ message: `网络错误!`, type: 'error' })
toast(`网络错误!`)
} else {
let msg = `未知错误:${error.response.status}, ${error.response.data.msg}`;
ElMessage({ message: msg, type: 'error' })
toast(msg)
};
};
@@ -69,10 +80,10 @@ const handleException = (error) => {
// 登出系统
const logout = () => {
const pageState = usePageState();
pageState.setIsLogin(true);
localStorage.setItem(CONSTANT.STORE_TOKEN_NAME, "");
pageState.setIsLogin(false);
localStorage.removeItem(CONSTANT.STORE_TOKEN_NAME);
setTimeout(() => {
window.location.href = '/';
window.location.href = '/login';
}, 500);
};
-33
View File
@@ -1,33 +0,0 @@
a {
text-decoration: none;
}
/* #main-container {
display: block;
margin-left: 0px;
padding: 0;
} */
.main-center-body {
font-family: Arial, sans-serif;
margin: 0 0 0 0;
padding: 0;
background-color: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
/* flex: 1; */
}
.container {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
max-width: 400px;
width: 100%;
/* margin-top: -10vh; */
/* flex: 1; */
}
-26
View File
@@ -1,26 +0,0 @@
textarea {
width: 99%;
padding: 2px;
margin-bottom: 10px;
border: 1px solid #ccc;
border-radius: 4px;
resize: vertical;
}
button {
width: 46%;
padding: 10px;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 3%;
margin-left: 1%;
margin-right: 1%;
}
@media (max-width: 768px) {
.container {
max-width: 90%;
}
}
-1
View File
@@ -1 +0,0 @@
@import './base.css';
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+357
View File
@@ -0,0 +1,357 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
// 使用 defineAsyncComponent 导入没有默认导出的组件
// const Dashboard = defineAsyncComponent(() => import("@/components/pages/dashboard/Dashboard.vue"))
// const SendLogs = defineAsyncComponent(() => import("@/components/pages/sendLogs/SendLogs.vue"))
// 导入 constant 模块,使用命名导入匹配 constant.js 的导出方式
import { CONSTANT } from '../constant.js'
import { LocalStieConfigUtils } from '@/util/localSiteConfig'
import { usePageState } from '@/store/page_sate.js'
import { useRoute, useRouter } from 'vue-router'
// 定义标签接口
interface TabRoute {
name: string;
path: string;
}
const route = useRoute()
const router = useRouter()
const pageState = usePageState()
const isAuthenticated = ref(Boolean(localStorage.getItem(CONSTANT.STORE_TOKEN_NAME)));
const isMobileMenuOpen = ref(false)
const isUserMenuOpen = ref(false)
const userAccount = ref('管理员')
const siteConfig = ref<any>({})
// 从JWT中解析用户名
const parseJwtUsername = (token: string): string => {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.username || payload.user || payload.name || '管理员'
} catch (error) {
console.error('解析JWT失败:', error)
return '管理员'
}
}
// 更新用户账号信息
const updateUserAccount = () => {
const token = localStorage.getItem(CONSTANT.STORE_TOKEN_NAME)
if (token) {
userAccount.value = parseJwtUsername(token)
} else {
userAccount.value = '管理员'
}
}
// 更新favicon
const updateFavicon = (logoSvg: string) => {
if (logoSvg) {
// 查找现有的favicon链接
let link = document.querySelector("link[rel*='icon']") as HTMLLinkElement
if (!link) {
// 如果不存在,创建新的favicon链接
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
// 将SVG转换为data URL
const svgBlob = new Blob([logoSvg], { type: 'image/svg+xml' })
const svgUrl = URL.createObjectURL(svgBlob)
link.href = svgUrl
link.type = 'image/svg+xml'
}
}
// 获取本地配置
const getLocalConfig = () => {
try {
const localConfig = LocalStieConfigUtils.getLocalConfig()
if (localConfig) {
siteConfig.value = localConfig
// 更新页面状态中的配置数据
if (pageState.setSiteConfigData) {
pageState.setSiteConfigData(localConfig)
}
// 更新网站标题
if (localConfig.title) {
document.title = localConfig.title
}
// 更新favicon
if (localConfig.logo) {
updateFavicon(localConfig.logo)
}
}
} catch (error) {
console.error('获取本地配置失败:', error)
}
}
// 获取最新配置并更新
const getLatestConfig = async () => {
try {
const latestConfig = await LocalStieConfigUtils.getLatestLocalConfig()
if (latestConfig) {
siteConfig.value = latestConfig
// 更新页面状态中的配置数据
if (pageState.setSiteConfigData) {
pageState.setSiteConfigData(latestConfig)
}
// 更新网站标题
if (latestConfig.title) {
document.title = latestConfig.title
}
// 更新favicon
if (latestConfig.logo) {
updateFavicon(latestConfig.logo)
}
}
} catch (error) {
console.error('获取最新配置失败:', error)
// 如果获取最新配置失败,尝试使用本地配置
getLocalConfig()
}
}
// 切换移动端菜单
const toggleMobileMenu = () => {
isMobileMenuOpen.value = !isMobileMenuOpen.value
}
// 切换用户菜单
const toggleUserMenu = () => {
isUserMenuOpen.value = !isUserMenuOpen.value
}
// 退出登录
const logout = () => {
localStorage.removeItem(CONSTANT.STORE_TOKEN_NAME)
isAuthenticated.value = false
isUserMenuOpen.value = false
router.push('/login')
}
// 监听localStorage变化
onMounted(() => {
// 初始化用户账号信息
updateUserAccount();
// 初始化配置信息
getLocalConfig();
// 如果已认证,获取最新配置
if (isAuthenticated.value) {
getLatestConfig();
}
// 定期检查token状态
const checkAuth = () => {
const wasAuthenticated = isAuthenticated.value;
isAuthenticated.value = Boolean(localStorage.getItem(CONSTANT.STORE_TOKEN_NAME));
// 如果认证状态发生变化,更新用户账号信息和配置
if (wasAuthenticated !== isAuthenticated.value) {
updateUserAccount();
if (isAuthenticated.value) {
// 用户刚登录,获取最新配置
getLatestConfig();
} else {
// 用户退出登录,使用本地配置
getLocalConfig();
}
}
};
// 监听storage事件
window.addEventListener('storage', checkAuth);
// 定期检查(处理同一页面内的变化)
const interval = setInterval(checkAuth, 1000);
// 点击外部关闭用户菜单
const handleClickOutside = (event: Event) => {
const target = event.target as Element;
if (!target.closest('.user-menu-container')) {
isUserMenuOpen.value = false;
}
};
document.addEventListener('click', handleClickOutside);
// 清理函数
return () => {
window.removeEventListener('storage', checkAuth);
clearInterval(interval);
document.removeEventListener('click', handleClickOutside);
};
});
// 定义标签及其对应的路由路径
const tabRoutes: TabRoute[] = [
{ name: '数据统计', path: '/' },
{ name: '发信日志', path: '/sendlogs' },
{ name: '托管消息', path: '/hostedmessage' },
{ name: '定时消息', path: '/cronmessages' },
{ name: '发信任务', path: '/sendtasks' },
{ name: '发信渠道', path: '/sendways' },
{ name: '设置', path: '/settings' }
];
const activeTab = ref('Dashboard');
// 处理标签点击事件,跳转到对应路由
const handleTabClick = (tab: TabRoute) => {
activeTab.value = tab.name;
// 使用 Vue Router 进行导航,保持单页应用状态
// 对于根路径,直接导航;对于其他路径,使用相对路径
if (tab.path === '/') {
router.push(tab.path);
} else {
// 去掉开头的斜杠,使用相对路径导航
const relativePath = tab.path.startsWith('/') ? tab.path.substring(1) : tab.path;
router.push(relativePath);
}
};
// 根据当前路由设置活动标签
const updateActiveTab = () => {
// 获取当前路径(去掉开头的斜杠)
const currentPath = route.path.startsWith('/') ? route.path.substring(1) : route.path;
// 查找当前路由对应的标签
const currentTab = tabRoutes.find(tab => {
// 对于根路径,需要精确匹配
if (tab.path === '/') {
return route.path === '/' || route.path === '';
}
// 对于其他路径,检查当前路由是否匹配
// 去掉开头的斜杠进行比较
const tabPath = tab.path.startsWith('/') ? tab.path.substring(1) : tab.path;
return currentPath === tabPath || route.path === tab.path;
});
// 如果找到对应标签,更新活动标签
if (currentTab) {
activeTab.value = currentTab.name;
}
};
// 初始化时设置活动标签
updateActiveTab();
// 监听路由变化并更新活动标签
watch(() => route.path, updateActiveTab);
// 计算属性:站点标题
const siteTitle = computed(() => {
return siteConfig.value?.title || '消息管理系统'
})
</script>
<template>
<router-view v-if="!isAuthenticated || route.path == '/login' || route.path == 'login'"></router-view>
<div class="min-h-screen bg-gray-50" v-else>
<!-- 顶部导航栏 -->
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo/品牌名称 -->
<div class="flex items-center">
<h1 class="text-lg sm:text-xl font-bold text-gray-900">{{ siteTitle }}</h1>
</div>
<!-- 桌面端导航 -->
<div class="hidden md:flex space-x-6 lg:space-x-8">
<button v-for="tab in tabRoutes" :key="tab.name" @click="handleTabClick(tab)" :class="[
'relative py-2 px-3 text-sm font-medium transition-all duration-200 rounded-md whitespace-nowrap',
activeTab === tab.name
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'
]">
{{ tab.name }}
<!-- 活动状态指示器 -->
<span v-if="activeTab === tab.name"
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-1 h-1 bg-blue-600 rounded-full"></span>
</button>
</div>
<!-- 右侧区域 -->
<div class="flex items-center space-x-4">
<!-- 用户账号下拉菜单 -->
<div class="relative user-menu-container">
<button @click="toggleUserMenu" class="flex items-center space-x-2 p-2 rounded-md hover:bg-gray-50 transition-colors">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
</svg>
</div>
<span class="hidden sm:block text-sm font-medium text-gray-700">{{ userAccount }}</span>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- 下拉菜单 -->
<div v-if="isUserMenuOpen" class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50">
<div class="py-1">
<div class="px-4 py-2 text-sm text-gray-500 border-b border-gray-100">
<div class="font-medium text-gray-900">{{ userAccount }}</div>
<!-- <div class="text-xs">当前登录账号</div> -->
</div>
<button @click="logout" class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors">
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>退出登录</span>
</div>
</button>
</div>
</div>
</div>
<!-- 移动端菜单按钮 -->
<button @click="toggleMobileMenu"
class="md:hidden p-2 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-50 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="!isMobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- 移动端菜单 -->
<div v-if="isMobileMenuOpen" class="md:hidden border-t border-gray-200 py-2">
<div class="flex flex-col space-y-1">
<button v-for="tab in tabRoutes" :key="tab.name" @click="handleTabClick(tab); isMobileMenuOpen = false"
:class="[
'text-left py-3 px-4 text-sm font-medium transition-all duration-200 rounded-md',
activeTab === tab.name
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'
]">
{{ tab.name }}
</button>
</div>
</div>
</div>
</nav>
<!-- 主要内容区域 -->
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<!-- <div class="bg-white rounded-lg shadow-sm border border-gray-200"> -->
<div class="">
<!-- 使用 router-view 显示嵌套路由的内容 -->
<router-view></router-view>
</div>
</main>
</div>
</template>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "vue-sonner"
import { ref, onMounted } from "vue"
import { Card, CardContent } from '@/components/ui/card'
// @ts-ignore
import { CONSTANT } from '@/constant';
// @ts-ignore
import { request } from '@/api/api';
import { usePageState } from '@/store/page_sate';
import { useRouter } from 'vue-router';
// @ts-ignore
import { LocalStieConfigUtils } from '@/util/localSiteConfig';
const siteConfigData = JSON.parse(CONSTANT.DEFALUT_SITE_CONFIG);
let logo = ref('data:image/svg+xml;base64,' + btoa(siteConfigData.logo));
let slogan = ref(siteConfigData.slogan);
// 设置默认网站标题
if (siteConfigData.title) {
document.title = siteConfigData.title;
}
let pageState = usePageState();
let router = useRouter();
let account = ref("");
let password = ref("");
// 站点演示模式
const demoSiteSet = () => {
// @ts-ignore
if (import.meta.env.VITE_RUN_MODE === 'demo') {
account.value = 'admin';
password.value = '123456';
}
}
let handleSubmit = async function () {
toast('注册功能还在开发的路上...');
}
// 更新favicon
const updateFavicon = (logoSvg: string) => {
if (logoSvg) {
// 查找现有的favicon链接
let link = document.querySelector("link[rel*='icon']") as HTMLLinkElement
if (!link) {
// 如果不存在,创建新的favicon链接
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
// 将SVG转换为data URL
const svgBlob = new Blob([logoSvg], { type: 'image/svg+xml' })
const svgUrl = URL.createObjectURL(svgBlob)
link.href = svgUrl
link.type = 'image/svg+xml'
}
}
// 登录页面加载时获取最新配置
onMounted(async () => {
demoSiteSet();
// 登录页面加载时获取最新配置
try {
await LocalStieConfigUtils.getLatestLocalConfig();
// 更新页面状态中的站点配置
pageState.setSiteConfigData(LocalStieConfigUtils.getLocalConfig());
// 更新当前页面的logo、slogan和网站标题
const siteConfig = LocalStieConfigUtils.getLocalConfig();
logo.value = 'data:image/svg+xml;base64,' + btoa(siteConfig.logo);
slogan.value = siteConfig.slogan;
// 更新网站标题
if (siteConfig.title) {
document.title = siteConfig.title;
}
// 更新favicon
if (siteConfig.logo) {
updateFavicon(siteConfig.logo);
}
} catch (error) {
console.warn('获取站点配置失败:', error);
}
});
// 登录
let clickLogin = async () => {
const rspe = await request.post('/auth', { username: account.value, passwd: password.value });
const rsp = rspe.data;
if (rsp.code != 200) {
toast(rsp.msg);
} else {
pageState.setToken(rsp.data.token);
router.push('/');
}
};
</script>
<template>
<div class="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
<div class="w-full max-w-sm md:max-w-3xl">
<div class="flex flex-col gap-6">
<Card class="overflow-hidden">
<CardContent class="grid p-0 md:grid-cols-2">
<form class="p-6 md:p-8">
<div class="flex flex-col gap-6">
<div class="flex flex-col items-center text-center">
<h1 class="text-2xl font-bold">
欢迎回来
</h1>
<p class="text-balance text-muted-foreground">
{{ slogan }}
</p>
</div>
<div class="grid gap-2">
<Label for="account">账号</Label>
<Input id="account" type="text" placeholder="" v-model="account" required />
</div>
<div class="grid gap-2">
<div class="flex items-center">
<Label for="password">密码</Label>
<!-- <a
href="#"
class="ml-auto text-sm underline-offset-2 hover:underline"
>
Forgot your password?
</a> -->
</div>
<Input id="password" type="password" v-model="password" required />
</div>
<Button type="submit" class="w-full active:scale-95 active:bg-gray-100 transition duration-150"
@click.prevent="clickLogin">
登录
</Button>
<!-- <div class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
<span class="relative z-10 bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
<div class="grid grid-cols-3 gap-4">
<Button variant="outline" class="w-full">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
<span class="sr-only">Login with Apple</span>
</Button>
<Button variant="outline" class="w-full">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
<span class="sr-only">Login with Google</span>
</Button>
<Button variant="outline" class="w-full">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
fill="currentColor"
/>
</svg>
<span class="sr-only">Login with Meta</span>
</Button>
</div> -->
<div class="text-center text-sm">
还没有账号
<a href="#" class="underline underline-offset-4" @click="handleSubmit">
注册
</a>
</div>
</div>
</form>
<div class="relative hidden bg-white md:block">
<div class="w-full h-full flex justify-center items-center bg-white">
<img :src="logo" alt="logo" class="max-w-full max-h-full" />
</div>
</div>
</CardContent>
</Card>
<!-- <div class="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
By clicking continue, you agree to our <a href="#">Terms of Service</a>
and <a href="#">Privacy Policy</a>.
</div> -->
</div>
</div>
</div>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
import { generateBizUniqueID } from '@/util/uuid'
import CronMessageForm from './CronMessageForm.vue'
interface Props {
open: boolean
}
interface Emits {
(e: 'save', data: any): void
(e: 'cancel'): void
(e: 'update:open', value: boolean): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
defineOptions({
name: 'AddCronMessages'
})
// 表单数据
const formData = reactive({
name: '',
cron_expression: '',
title: '',
content: '',
task_id: '',
url: ''
})
// 加载状态
const loading = ref(false)
// 提交表单
const handleSubmit = async () => {
loading.value = true
try {
let postData = {
"name": formData.name,
"id": generateBizUniqueID("C"),
"title": formData.title,
"content": formData.content,
"cron": formData.cron_expression,
"url": formData.url,
"task_id": formData.task_id
}
const rsp = await request.post('/cronmessages/addone', postData)
if (rsp.data.code === 200) {
toast.success(rsp.data.msg)
setTimeout(() => {
window.location.reload()
}, 1000)
} else {
toast.success(rsp.data.msg)
}
} finally {
loading.value = false
}
}
// 取消操作
const handleCancel = () => {
emit('cancel')
emit('update:open', false)
}
</script>
<template>
<CronMessageForm
v-model="formData"
mode="add"
:loading="loading"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AddCronMessages'
})
</script>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { request } from '@/api/api'
interface CronMessageFormData {
id?: number
name: string
cron_expression: string
title: string
content: string
task_id: string
url: string
}
interface Props {
modelValue: CronMessageFormData
mode: 'add' | 'edit'
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: CronMessageFormData): void
(e: 'submit'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const emit = defineEmits<Emits>()
defineOptions({
name: 'CronMessageForm'
})
// 本地表单数据
const localFormData = reactive<CronMessageFormData>({ ...props.modelValue })
// 监听外部数据变化
watch(() => props.modelValue, (newValue) => {
Object.assign(localFormData, newValue)
}, { deep: true })
// 监听本地数据变化,同步到外部
watch(localFormData, (newValue) => {
emit('update:modelValue', { ...newValue })
}, { deep: true })
// 可用的发信任务列表
const availableTasks = ref<Array<{ id: number, name: string }>>([])
// 常用的 Cron 表达式模板
const cronTemplates = [
{ label: '每分钟', value: '* * * * *' },
{ label: '每5分钟', value: '*/5 * * * *' },
{ label: '每小时', value: '0 * * * *' },
{ label: '每天凌晨2点', value: '0 2 * * *' },
{ label: '每周一凌晨2点', value: '0 2 * * 1' },
{ label: '每月1号凌晨2点', value: '0 2 1 * *' }
]
// 加载可用的发信任务
const loadAvailableTasks = async () => {
try {
const rsp = await request.get('/sendtasks/list', { params: { page: 1, size: 100 } })
availableTasks.value = rsp.data.data.lists.map((task: any) => ({
id: task.id,
name: task.name
}))
} catch (error) {
console.error('加载发信任务失败:', error)
}
}
// 应用 Cron 模板
const applyCronTemplate = (template: string) => {
localFormData.cron_expression = template
}
// 提交表单
const handleSubmit = () => {
emit('submit')
}
// 取消操作
const handleCancel = () => {
emit('cancel')
}
// 组件挂载时加载数据
loadAvailableTasks()
</script>
<template>
<div class="space-y-3">
<div class="space-y-1">
<Label for="name" class="text-sm">定时消息名称</Label>
<Input id="name" v-model="localFormData.name" placeholder="请输入定时消息名称" class="h-8" />
</div>
<div class="space-y-1">
<Label for="task_id" class="text-sm">关联发信任务</Label>
<Select v-model="localFormData.task_id">
<SelectTrigger class="h-8">
<SelectValue placeholder="选择要关联的发信任务" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="task in availableTasks" :key="task.id" :value="String(task.id)">
{{ task.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="cron_expression" class="text-sm">Cron表达式</Label>
<Input id="cron_expression" v-model="localFormData.cron_expression" placeholder="请输入Cron表达式,如: 0 2 * * *" class="h-8" />
<div class="text-xs text-gray-500">
<p class="mb-1">常用模板</p>
<div class="flex flex-wrap gap-1">
<Button v-for="template in cronTemplates" :key="template.value" size="sm" variant="outline"
@click="applyCronTemplate(template.value)" class="h-6 px-2 text-xs">
{{ template.label }}
</Button>
</div>
</div>
</div>
<div class="space-y-1">
<Label for="title" class="text-sm">标题</Label>
<Input id="title" v-model="localFormData.title" placeholder="请输入定时消息标题" class="h-8" />
</div>
<div class="space-y-1">
<Label for="content" class="text-sm">内容</Label>
<Textarea id="content" v-model="localFormData.content" placeholder="请输入定时消息内容" rows="2" class="min-h-[60px] resize-none" />
</div>
<div class="space-y-1">
<Label for="url" class="text-sm">url可选</Label>
<Input id="url" v-model="localFormData.url" placeholder="请输入定时消息描述" class="h-8" />
</div>
<div class="flex justify-end space-x-2 pt-2">
<Button variant="outline" @click="handleCancel" size="sm" :disabled="loading">
取消
</Button>
<Button @click="handleSubmit" size="sm" :disabled="loading">
{{ mode === 'add' ? '创建定时消息' : '更新定时消息' }}
</Button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'CronMessageForm'
})
</script>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import Pagination from '@/components/ui/Pagination.vue'
import AddCronMessages from './AddCronMessages.vue'
import EditCronMessages from './EditCronMessages.vue'
import { toast } from 'vue-sonner'
import { useRoute, useRouter } from 'vue-router';
import { request } from '@/api/api';
// @ts-ignore
import { getPageSize } from '@/util/pageUtils';
interface CronMessageItem {
id: string
title: string
name: string
content: string
cron: string
cron_expression: string
task_id: string
task_name: string
enable: number
status: boolean
created_on: string
modified_on: string
next_time?: string
url: string
}
const route = useRoute();
const router = useRouter();
let state = reactive({
tableData: [] as CronMessageItem[],
total: 0,
currPage: 1,
pageSize: getPageSize(),
search: '',
optionValue: '',
})
// 状态过滤
const selectedStatus = ref('all')
// 新增定时消息 Dialog 状态
const isAddCronMessageDialogOpen = ref(false)
// 编辑定时消息 Dialog 状态
const isEditCronMessageDialogOpen = ref(false)
const editCronMessageData = ref<CronMessageItem | null>(null)
// 处理保存新定时消息
const handleSaveCronMessage = (_data: any) => {
// 保存成功后刷新列表
queryListDataWithStatus()
}
// 总页数
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
// 打开编辑定时消息Dialog
const openEditCronMessageDialog = (cronMessage: CronMessageItem) => {
editCronMessageData.value = cronMessage
isEditCronMessageDialogOpen.value = true
}
// 处理编辑定时消息保存
const handleEditCronMessage = (_data: any) => {
// 保存成功后刷新列表
queryListDataWithStatus()
}
// 处理查看日志
const handleViewLogs = (cronMessage: CronMessageItem) => {
// 跳转到定时消息日志页面,携带cronMessageId参数
router.push(`/sendlogs?taskid=${cronMessage.task_id}`)
}
// 切换状态
const toggleStatus = async (cronMessage: CronMessageItem) => {
const newStatus = !cronMessage.enable ? 1 : 0
cronMessage.enable = newStatus
const rsp = await request.post('/cronmessages/edit', cronMessage)
if (rsp.data.code === 200) {
cronMessage.enable = newStatus
toast.success(rsp.data.msg)
}
}
const changePage = async (page: number) => {
if (page >= 1 && page <= totalPages.value) {
state.currPage = page
await queryListDataWithStatus()
}
}
//触发过滤筛选
const filterFunc = async () => {
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
}
// 查询数据(包含状态过滤)
const queryListDataWithStatus = async () => {
const statusParam = selectedStatus.value === 'all' ? '' : selectedStatus.value;
await queryListData(state.currPage, state.pageSize, state.search, '', '', statusParam);
}
const queryListData = async (page: number, size: number, name = '', taskType = '', query = '', status = '') => {
let params: any = { page: page, size: size, name: name, type: taskType, query: query };
if (status !== '') {
params.status = status;
}
const rsp = await request.get('/cronmessages/list', { params: params });
state.tableData = await rsp.data.data.lists;
state.total = await rsp.data.data.total;
}
// 删除定时消息
const handleDelete = async (id: string) => {
const rsp = await request.post('/cronmessages/delete', { id: id });
if (rsp.status == 200 && await rsp.data.code == 200) {
toast.success(rsp.data.msg);
setTimeout(() => {
window.location.reload();
}, 1000);
}
}
onMounted(async () => {
// 初始化查询
state.search = route.query.name?.toString() || '';
await queryListData(
1,
state.pageSize,
route.query.name?.toString() || '',
route.query.task_type?.toString() || ''
);
});
</script>
<template>
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
<div class="flex-1 sm:flex-initial">
<Input v-model="state.search" placeholder="搜索定时任务名称..." class="w-full sm:w-64" @keyup.enter="filterFunc"
@blur="filterFunc" />
</div>
</div>
<div class="flex-shrink-0">
<Dialog v-model:open="isAddCronMessageDialogOpen">
<DialogTrigger as-child>
<Button variant="default" class="w-full sm:w-auto bg-gray-800 hover:bg-gray-900">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
新增定时消息
</Button>
</DialogTrigger>
<DialogContent class="w-[500px] max-w-[90vw]">
<DialogHeader>
<DialogTitle>新增定时消息</DialogTitle>
</DialogHeader>
<div class="px-4 pb-4">
<AddCronMessages v-model:open="isAddCronMessageDialogOpen" @save="handleSaveCronMessage"
@cancel="() => isAddCronMessageDialogOpen = false" />
</div>
</DialogContent>
</Dialog>
</div>
</div>
<!-- 表格 -->
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-20">ID</TableHead>
<TableHead>名称</TableHead>
<TableHead>内容</TableHead>
<TableHead>Cron表达式</TableHead>
<TableHead>关联任务</TableHead>
<TableHead>下次执行时间</TableHead>
<TableHead>创建时间</TableHead>
<TableHead class="text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- 空数据展示 -->
<TableRow v-if="state.tableData.length === 0">
<TableCell colspan="8" class="text-center py-12">
<EmptyTableState title="暂无定时消息" description="还没有配置任何定时消息,请先添加定时消息" />
</TableCell>
</TableRow>
<!-- 数据行 -->
<TableRow v-for="cronMessage in state.tableData" :key="cronMessage.id">
<TableCell>{{ cronMessage.id }}</TableCell>
<TableCell class="max-w-32 truncate" :title="cronMessage.title">{{ cronMessage.title }}</TableCell>
<TableCell class="max-w-32 truncate" :title="cronMessage.content">{{ cronMessage.content }}</TableCell>
<TableCell>
<code class="bg-gray-100 px-2 py-1 rounded text-sm">{{ cronMessage.cron }}</code>
</TableCell>
<TableCell class="max-w-24 truncate" :title="cronMessage.task_id">{{ cronMessage.task_id }}</TableCell>
<TableCell class="max-w-32 truncate" :title="cronMessage.next_time || '-'">{{ cronMessage.next_time || '-' }}
</TableCell>
<TableCell class="max-w-32 truncate" :title="cronMessage.created_on">{{ cronMessage.created_on }}</TableCell>
<!-- <TableCell class="text-center">
<Badge :class="cronMessage.status === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'">
{{ getStatusText(cronMessage.status) }}
</Badge>
</TableCell> -->
<TableCell class="text-center space-x-2">
<Button size="sm" variant="outline" @click="handleViewLogs(cronMessage)">日志</Button>
<Button size="sm" variant="outline" @click="openEditCronMessageDialog(cronMessage)">编辑</Button>
<Button size="sm" variant="outline" class="text-red-500 border-red-300 hover:bg-red-50
hover:border-red-400 hover:text-red-600 hover:shadow-md
transition-all duration-200" @click="handleDelete(cronMessage.id)">删除</Button>
<Switch :model-value="cronMessage.enable === 1" @update:model-value="toggleStatus(cronMessage)" />
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 分页 -->
<Pagination :total="state.total" :current-page="state.currPage" :page-size="state.pageSize"
@page-change="changePage" />
<!-- 编辑定时消息Dialog -->
<Dialog v-model:open="isEditCronMessageDialogOpen">
<DialogContent class="w-[500px] max-w-[90vw] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader class="flex-shrink-0">
<DialogTitle>编辑定时消息</DialogTitle>
</DialogHeader>
<div class="px-4 pb-4 flex-1 overflow-y-auto">
<EditCronMessages v-model:open="isEditCronMessageDialogOpen" :cron-message="editCronMessageData"
@save="handleEditCronMessage" />
</div>
</DialogContent>
</Dialog>
</div>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
import CronMessageForm from './CronMessageForm.vue'
interface CronMessageItem {
id: string
name: string
title: string
content: string
cron: string
url: string
task_id: string
enable: number
status: boolean
}
interface Props {
open: boolean
cronMessage: CronMessageItem | null
}
interface Emits {
(e: 'save', data: any): void
(e: 'cancel'): void
(e: 'update:open', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
defineOptions({
name: 'EditCronMessages'
})
// 表单数据
const formData = reactive({
name: '',
cron_expression: '',
title: '',
content: '',
task_id: '',
url: ''
})
// 加载状态
const loading = ref(false)
// 提交表单
const handleSubmit = async () => {
if (!props.cronMessage) return
loading.value = true
try {
let postData = {
"name": formData.name,
"id": props.cronMessage.id,
"title": formData.title,
"content": formData.content,
"cron": formData.cron_expression,
"url": formData.url,
"task_id": formData.task_id,
"enable": props.cronMessage.enable,
}
const rsp = await request.post('/cronmessages/edit', postData)
if (rsp.data.code === 200) {
toast.success(rsp.data.msg)
setTimeout(() => {
window.location.reload()
}, 1000)
} else {
toast.success(rsp.data.msg)
}
} finally {
loading.value = false
}
}
// 取消操作
const handleCancel = () => {
emit('cancel')
emit('update:open', false)
}
// 监听 cronMessage 变化,更新表单数据
watch(
() => props.cronMessage,
(newCronMessage) => {
if (newCronMessage) {
formData.name = newCronMessage.name
formData.cron_expression = newCronMessage.cron
formData.title = newCronMessage.title
formData.content = newCronMessage.content
formData.task_id = newCronMessage.task_id
formData.url = newCronMessage.url
}
},
{ immediate: true }
)
</script>
<template>
<CronMessageForm
v-model="formData"
mode="edit"
:loading="loading"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'EditCronMessages'
})
</script>
@@ -0,0 +1,32 @@
<template>
<Card class="w-full">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">
{{ title }}
</CardTitle>
<component :is="icon" class="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ value }}</div>
<p class="text-xs text-muted-foreground">{{ description }}</p>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import type { Component } from 'vue'
defineProps<{
title: string
value: string | number
description?: string
icon?: Component
}>()
</script>
<script lang="ts">
export default {
name: 'CardNum'
}
</script>
+10
View File
@@ -0,0 +1,10 @@
import { DefineComponent } from 'vue';
declare const CardNum: DefineComponent<{
title: string;
value: string | number;
description?: string;
icon?: any;
}>;
export default CardNum;
@@ -0,0 +1,412 @@
<script setup lang="ts">
import StatCard from '@/components/pages/dashboard/CardNum.vue'
import { DatabaseIcon, BarChartIcon, SendIcon, CheckCircleIcon, XCircleIcon } from 'lucide-vue-next'
// import { LineChart } from "@/components/ui/chart-line"
import { onMounted, reactive } from 'vue';
import { request } from '@/api/api';
// import VueApexCharts from 'vue3-apexcharts'
import ApexCharts from 'apexcharts'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
interface SendData {
day: string
day_failed_num: number
day_succ_num: number
num: number
succ_num: number
}
interface CateData {
count_num: number
way_name: string
}
let state = reactive({
data: {
message_total_num: 0,
hosted_message_total_num: 0,
today_total_num: 0,
today_succ_num: 0,
today_failed_num: 0,
way_cate_data: [] as CateData[],
latest_send_data: [] as SendData[],
}
});
const getStatisticData = async () => {
try {
const rsp = await request.get('/statistic');
if (rsp && rsp.data && rsp.data.code == 200) {
state.data = rsp.data.data;
// 数据加载完成后重新渲染图表
setTimeout(() => {
renderLineChart();
renderPieChart();
}, 100);
} else {
console.error('Failed to load statistics:', rsp?.data?.msg || 'Unknown error');
}
} catch (error) {
console.error('Error loading statistics:', error);
}
}
const renderLineChart = () => {
const options = {
series: [
{
name: '发送总数',
data: state.data.latest_send_data.length > 0
? state.data.latest_send_data.map(item => item.num || 0)
: []
},
{
name: '发送成功数',
data: state.data.latest_send_data.length > 0
? state.data.latest_send_data.map(item => item.day_succ_num || 0)
: []
},
{
name: '发送失败数',
data: state.data.latest_send_data.length > 0
? state.data.latest_send_data.map(item => item.day_failed_num || 0)
: []
},
],
chart: {
type: 'line',
height: 350,
toolbar: { show: false },
background: 'transparent',
animations: {
enabled: true,
easing: 'easeinout',
speed: 800,
animateGradually: {
enabled: true,
delay: 150
},
dynamicAnimation: {
enabled: true,
speed: 350
}
},
dropShadow: {
enabled: true,
color: '#000',
top: 18,
left: 7,
blur: 10,
opacity: 0.2
}
},
stroke: {
curve: 'smooth',
width: 3,
lineCap: 'round'
},
markers: {
size: 6,
colors: ['#3b82f6', '#10b981', '#ef4444'],
strokeColors: '#fff',
strokeWidth: 2,
hover: {
size: 8,
sizeOffset: 3
}
},
xaxis: {
categories: state.data.latest_send_data.length > 0
? state.data.latest_send_data.map(item => item.day)
: [],
axisBorder: {
show: false
},
axisTicks: {
show: false
},
labels: {
style: {
colors: '#64748b',
fontSize: '12px',
fontFamily: 'Inter, sans-serif'
}
}
},
yaxis: {
labels: {
style: {
colors: '#64748b',
fontSize: '12px',
fontFamily: 'Inter, sans-serif'
},
formatter: function (val: number) {
return val + ' 条'
}
}
},
colors: ['#3b82f6', '#10b981', '#ef4444'], // 蓝色表示总数,绿色表示成功,红色表示失败
fill: {
type: 'gradient',
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0.5,
gradientToColors: ['#60a5fa', '#34d399', '#f87171'],
inverseColors: false,
opacityFrom: 0.8,
opacityTo: 0.1,
stops: [0, 100]
}
},
grid: {
borderColor: '#e2e8f0',
strokeDashArray: 3,
xaxis: {
lines: {
show: false
}
},
yaxis: {
lines: {
show: true
}
},
padding: {
top: 0,
right: 0,
bottom: 0,
left: 0
}
},
legend: {
position: 'top',
horizontalAlign: 'right',
floating: true,
offsetY: -25,
offsetX: -5,
fontSize: '12px',
fontFamily: 'Inter, sans-serif',
markers: {
width: 8,
height: 8,
radius: 4
}
},
tooltip: {
enabled: true,
shared: true,
intersect: false,
theme: 'light',
style: {
fontSize: '12px',
fontFamily: 'Inter, sans-serif'
},
x: {
show: true,
format: 'MM/dd'
},
y: {
formatter: function (val: number, { seriesIndex: _seriesIndex }: { seriesIndex: number }) {
return val + ' 条'
}
},
marker: {
show: true
},
custom: function ({ series, seriesIndex: _seriesIndex, dataPointIndex, w }: { series: number[][], seriesIndex: number, dataPointIndex: number, w: any }) {
const successCount = series[1][dataPointIndex];
const failedCount = series[2][dataPointIndex];
const total = successCount + failedCount;
const successRate = total > 0 ? ((successCount / total) * 100).toFixed(1) : '0.0';
return `
<div class="bg-white p-3 rounded-lg shadow-lg border">
<div class="font-medium text-gray-900 mb-2">${w.globals.categoryLabels[dataPointIndex]}</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
<span class="text-sm text-gray-600">成功:</span>
</span>
<span class="text-sm font-medium text-gray-900">${successCount} 条</span>
</div>
<div class="flex items-center justify-between">
<span class="flex items-center">
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
<span class="text-sm text-gray-600">失败:</span>
</span>
<span class="text-sm font-medium text-gray-900">${failedCount} 条</span>
</div>
<div class="border-t pt-1 mt-2">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">成功率:</span>
<span class="text-sm font-medium text-green-600">${successRate}%</span>
</div>
</div>
</div>
</div>
`;
}
},
responsive: [{
breakpoint: 768,
options: {
chart: {
height: 300
},
legend: {
position: 'bottom',
offsetY: 0
}
}
}]
}
const chart = new ApexCharts(document.querySelector("#sales-chart"), options)
console.log(1111);
chart.render();
console.log(chart);
}
const renderPieChart = () => {
const options = {
series: state.data.way_cate_data.length > 0
? state.data.way_cate_data.map(item => item.count_num)
: [],
chart: {
type: 'pie',
height: 350,
toolbar: { show: false },
background: 'transparent',
animations: {
enabled: true,
easing: 'easeinout',
speed: 800,
animateGradually: {
enabled: true,
delay: 150
},
dynamicAnimation: {
enabled: true,
speed: 350
}
}
},
labels: state.data.way_cate_data.length > 0
? state.data.way_cate_data.map(item => item.way_name)
: [],
colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'],
legend: {
position: 'bottom',
fontSize: '10px',
fontFamily: 'Inter, sans-serif',
markers: {
width: 8,
height: 8,
radius: 4
}
},
plotOptions: {
pie: {
donut: {
size: '0%'
},
expandOnClick: true
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toFixed(1) + '%'
},
style: {
fontSize: '10px',
fontFamily: 'Inter, sans-serif',
fontWeight: 'bold'
}
},
tooltip: {
enabled: true,
theme: 'light',
style: {
fontSize: '12px',
fontFamily: 'Inter, sans-serif'
},
y: {
formatter: function (val: number) {
return val + ' 条'
}
}
},
responsive: [{
breakpoint: 768,
options: {
chart: {
height: 300
},
legend: {
position: 'bottom'
}
}
}]
}
const pieChart = new ApexCharts(document.querySelector("#pie-chart"), options)
pieChart.render();
}
onMounted(() => {
getStatisticData();
// renderLineChart();
})
</script>
<template>
<div class="w-[90%] mx-auto grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard title="推送记录留存数" :value="state.data.message_total_num" description="" :icon="DatabaseIcon" />
<StatCard title="托管消息数" :value="state.data.hosted_message_total_num" description="" :icon="BarChartIcon" />
<StatCard title="今日发送总数" :value="state.data.today_total_num" description="" :icon="SendIcon" />
<StatCard title="今日成功数" :value="state.data.today_succ_num" description="" :icon="CheckCircleIcon" />
<StatCard title="今日失败数" :value="state.data.today_failed_num" description="" :icon="XCircleIcon" />
</div>
<!-- 折线图 -->
<!-- <LineChart
:data="data"
index="year"
:categories="['Export Growth Rate', 'Import Growth Rate']"
:y-formatter="(tick, i) => {
return typeof tick === 'number'
? `$ ${new Intl.NumberFormat('us').format(tick).toString()}`
: ''
}"
/> -->
<!-- 图表区域 -->
<div class="w-[90%] mx-auto mt-8 grid grid-cols-1 lg:grid-cols-10 gap-6">
<!-- 折线图 -->
<Card class="w-full lg:col-span-7">
<CardHeader>
<CardTitle>消息发送趋势</CardTitle>
<CardDescription>最近30天的发送情况统计</CardDescription>
</CardHeader>
<CardContent>
<div id="sales-chart" class="w-full h-[350px]"></div>
</CardContent>
</Card>
<!-- 饼图 -->
<Card class="w-full lg:col-span-3">
<CardHeader>
<CardTitle>发送渠道分布</CardTitle>
<CardDescription>各发送渠道的使用情况统计</CardDescription>
</CardHeader>
<CardContent>
<div id="pie-chart" class="w-full h-[350px]"></div>
</CardContent>
</Card>
</div>
</template>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import Pagination from '@/components/ui/Pagination.vue'
import { useRoute } from 'vue-router';
import { request } from '@/api/api';
// @ts-ignore
import { getPageSize } from '@/util/pageUtils';
interface HostedMessageItem {
id: number
title: string
content: string
url?: string
created_on: string
modified_on: string
status: number
}
const router = useRoute();
let state = reactive({
tableData: [] as HostedMessageItem[],
total: 0,
currPage: 1,
pageSize: getPageSize(),
search: '',
optionValue: '',
})
// 状态过滤
// const selectedStatus = ref('all')
// Sheet 相关状态
const isSheetOpen = ref(false)
const selectedLog = ref('')
const selectedTaskName = ref('')
// 总页数
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
const getStatusText = (status: number) => {
return status === 1 ? '成功' : '失败'
}
// 打开消息详情Sheet
const openMessageSheet = (message: HostedMessageItem) => {
selectedLog.value = formatMessageDisplayHtml(message);
selectedTaskName.value = message.title
isSheetOpen.value = true
}
const changePage = async (page: number) => {
if (page >= 1 && page <= totalPages.value) {
state.currPage = page
await queryListData(
state.currPage,
state.pageSize,
state.search,
state.optionValue
)
}
}
// 格式化处理显示的消息文本
const formatMessageDisplayHtml = (message: HostedMessageItem) => {
let content = `标题: ${message.title}\n\n内容: ${message.content}`;
if (message.url) {
content += `\n\nURL: ${message.url}`;
}
content += `\n\n创建时间: ${message.created_on}`;
content += `\n修改时间: ${message.modified_on}`;
return content;
}
//触发过滤筛选
const filterFunc = async () => {
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
}
const queryListData = async (page: number, size: number, text = '', query = '') => {
let params: any = { page: page, size: size, text: text, query: query };
const rsp = await request.get('/hostedmessages/list', { params: params });
state.tableData = await rsp.data.data.lists || [];
state.total = await rsp.data.data.total || 0;
}
onMounted(async () => {
// 页面加载触发消息显示
state.search = router.query.name?.toString() || '';
await queryListData(
1,
state.pageSize,
router.query.name?.toString() || '',
router.query.query?.toString() || ''
);
});
</script>
<template>
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div class="flex-1 sm:flex-initial">
<Input v-model="state.search" placeholder="搜索消息..." class="w-full sm:w-64" @keyup.enter="filterFunc"
@blur="filterFunc" />
</div>
</div>
<!-- 表格 -->
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-20">ID</TableHead>
<TableHead>消息标题</TableHead>
<TableHead>消息内容</TableHead>
<TableHead>创建时间</TableHead>
<TableHead class="text-center">详情/状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- 空数据展示 -->
<TableRow v-if="state.tableData.length === 0">
<TableCell colspan="5" class="text-center py-12">
<EmptyTableState
title="暂无托管消息"
description="还没有任何托管消息记录"
>
<template #icon>
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</template>
</EmptyTableState>
</TableCell>
</TableRow>
<!-- 数据行 -->
<TableRow v-for="message in state.tableData" :key="message.id">
<TableCell>{{ message.id }}</TableCell>
<TableCell>{{ message.title }}</TableCell>
<TableCell class="max-w-xs truncate" :title="formatMessageDisplayHtml(message)">{{ message.content }}</TableCell>
<TableCell>{{ message.created_on }}</TableCell>
<TableCell class="text-center space-x-2">
<Button size="sm" variant="outline" @click="openMessageSheet(message)">查看</Button>
<!-- <Button size="sm" variant="destructive">删除</Button> -->
<Badge :class="message.status === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'">
{{ getStatusText(message.status) }}
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 分页 -->
<Pagination
:total="state.total"
:current-page="state.currPage"
:page-size="state.pageSize"
@page-change="changePage"
/>
<!-- 消息详情Sheet -->
<Sheet v-model:open="isSheetOpen">
<SheetContent class="w-[600px] sm:w-[900px] lg:w-[1000px]">
<SheetHeader>
<SheetTitle>{{ selectedTaskName }} - 托管消息详情</SheetTitle>
</SheetHeader>
<div class="mt-6">
<div class="bg-gray-50 p-4 rounded-lg">
<pre class="whitespace-pre-wrap text-sm font-mono">{{ selectedLog }}</pre>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</template>
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import Pagination from '@/components/ui/Pagination.vue'
import { useRoute } from 'vue-router';
import { request } from '@/api/api';
// @ts-ignore
import { getPageSize } from '@/util/pageUtils';
interface LogItem {
id: number
task_id: number
task_name: string
log: string
created_on: string
caller_ip?: string
status: number
}
const router = useRoute();
let state = reactive({
tableData: [] as LogItem[],
total: 0,
currPage: 1,
pageSize: getPageSize(),
search: '',
optionValue: '',
})
// 状态过滤
const selectedStatus = ref('all')
// Sheet 相关状态
const isSheetOpen = ref(false)
const selectedLog = ref('')
const selectedTaskName = ref('')
// 总页数
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
const getStatusText = (status: number) => {
return status === 1 ? '成功' : '失败'
}
// 打开日志详情Sheet
const openLogSheet = (task: LogItem) => {
selectedLog.value = formatLogDisplayHtml(task);
selectedTaskName.value = task.task_name
isSheetOpen.value = true
}
const changePage = async (page: number) => {
if (page >= 1 && page <= totalPages.value) {
state.currPage = page
await queryListData(
state.currPage,
state.pageSize,
state.search,
state.optionValue
)
}
}
// 格式化处理显示的日志文本
const formatLogDisplayHtml = (task: LogItem) => {
let log = task.log;
log += '\n';
if (task.caller_ip) {
log += `调用来源IP${task.caller_ip}`;
};
return log;
}
//触发过滤筛选
const filterFunc = async () => {
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
}
// 按状态过滤
const filterByStatus = async (value: any) => {
if (value) {
state.currPage = 1; // 重置到第一页
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue, JSON.stringify({
status: selectedStatus.value === 'all' ? '' : selectedStatus.value
}));
}
}
const queryListData = async (page: number, size: number, name = '', taskid = '', query = '', _status = '') => {
let params: any = { page: page, size: size, name: name, taskid: taskid, query: query };
if (selectedStatus.value !== '' && selectedStatus.value !== 'all') {
params.query = JSON.stringify({
status: selectedStatus.value === 'all' ? '' : selectedStatus.value
})
}
const rsp = await request.get('/sendlogs/list', { params: params });
state.tableData = await rsp.data.data.lists;
// state.tableData = []
state.total = await rsp.data.data.total;
}
onMounted(async () => {
// 页面加载触发日志显示
state.search = router.query.name?.toString() || '';
await queryListData(
1,
state.pageSize,
router.query.name?.toString() || '',
router.query.taskid?.toString() || '',
router.query.query?.toString() || ''
);
});
</script>
<template>
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div class="flex-1 sm:flex-initial">
<Input v-model="state.search" placeholder="搜索任务..." class="w-full sm:w-64" @keyup.enter="filterFunc"
@blur="filterFunc" />
</div>
<div class="flex-1 sm:flex-initial">
<Select v-model="selectedStatus" class="w-full" @update:model-value="filterByStatus">
<SelectTrigger>
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="1">成功</SelectItem>
<SelectItem value="0">失败</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<!-- 表格 -->
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-20">ID</TableHead>
<TableHead>任务名称</TableHead>
<TableHead>发信日志</TableHead>
<TableHead>发送时间</TableHead>
<TableHead class="text-center">详情/状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- 空数据展示 -->
<TableRow v-if="state.tableData.length === 0">
<TableCell colspan="5" class="text-center py-12">
<EmptyTableState
title="暂无发信日志"
description="还没有任何发信日志记录"
>
<template #icon>
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</template>
</EmptyTableState>
</TableCell>
</TableRow>
<!-- 数据行 -->
<TableRow v-for="task in state.tableData" :key="task.id">
<TableCell>{{ task.task_id }}</TableCell>
<TableCell>{{ task.task_name }}</TableCell>
<TableCell class="max-w-xs truncate" :title="formatLogDisplayHtml(task)">{{ task.log }}</TableCell>
<TableCell>{{ task.created_on }}</TableCell>
<TableCell class="text-center space-x-2">
<Button size="sm" variant="outline" @click="openLogSheet(task)">查看</Button>
<!-- <Button size="sm" variant="destructive">删除</Button> -->
<Badge :class="task.status === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'">
{{ getStatusText(task.status) }}
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 分页 -->
<Pagination
:total="state.total"
:current-page="state.currPage"
:page-size="state.pageSize"
@page-change="changePage"
/>
<!-- 日志详情Sheet -->
<Sheet v-model:open="isSheetOpen">
<SheetContent class="w-[600px] sm:w-[900px] lg:w-[1000px]">
<SheetHeader>
<SheetTitle>{{ selectedTaskName }} - 发信日志详情</SheetTitle>
</SheetHeader>
<div class="mt-6">
<div class="bg-gray-50 p-4 rounded-lg">
<pre class="whitespace-pre-wrap text-sm font-mono">{{ selectedLog }}</pre>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref, defineEmits } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { generateBizUniqueID } from '@/util/uuid'
import { request } from '@/api/api'
import { toast } from 'vue-sonner'
// 组件emits
const emit = defineEmits<{
'save': [data: any]
'cancel': []
}>()
// 状态管理
const inputValue = ref('')
// 处理取消
const handleCancel = () => {
inputValue.value = ''
emit('cancel')
}
// 添加一条任务
const handleSubmit = async () => {
const taskId = generateBizUniqueID('T');
const postData: Record<string, any> = {
id: taskId,
name: inputValue.value.trim(),
ins_data: []
}
const rsp = await request.post('/sendtasks/ins/addmany', postData);
if (await rsp.data.code == 200) {
toast.success(rsp.data.msg);
setTimeout(() => {
window.location.reload();
}, 1000);
}
}
</script>
<template>
<div class="space-y-6 p-6">
<!-- 输入框区域 -->
<div class="space-y-2">
<Label for="task-name">任务名称</Label>
<Input id="task-name" v-model="inputValue" placeholder="请输入任务名称" @keyup.enter="handleSubmit" class="w-full" />
</div>
<!-- 按钮区域 -->
<div class="flex justify-end gap-2">
<Button variant="outline" @click="handleCancel">
取消
</Button>
<Button variant="default" class="!bg-gray-800 !hover:bg-gray-900 text-white" @click="handleSubmit"
:disabled="!inputValue.trim()">
保存
</Button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AddTasks'
})
</script>
@@ -0,0 +1,166 @@
<script lang="ts">
import { ref, defineComponent } from 'vue'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
// @ts-ignore
import { ApiStrGenerate } from '@/util/viewApi'
export default defineComponent({
name: 'ApiCodeViewer',
components: {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Badge
},
props: {
open: {
type: Boolean,
default: false
},
taskData: {
type: Object,
default: null
}
},
emits: ['update:open'],
setup(props, { emit }) {
// 处理关闭事件
const handleUpdateOpen = (value: boolean) => {
emit('update:open', value)
}
// 当前选中的标签
const activeTab = ref('curl')
// 代码语言选项
const codeLanguages = [
{ value: 'curl', label: 'cURL', icon: '🌐' },
{ value: 'javascript', label: 'JS', icon: '🟨' },
{ value: 'python', label: 'Python', icon: '🐍' },
{ value: 'php', label: 'PHP', icon: '🐘' },
{ value: 'golang', label: 'Go', icon: '🐹' },
{ value: 'java', label: 'Java', icon: '☕' },
{ value: 'rust', label: 'Rust', icon: '🦀' }
]
// 生成API代码示例
const generateApiCode = (language: string) => {
const taskId = props.taskData?.id || 'TASK_ID'
const options = { html: false, markdown: false, url: false }
switch (language) {
case 'curl':
return ApiStrGenerate.getCurlString(taskId, options)
case 'javascript':
return ApiStrGenerate.getNodeString(taskId, options)
case 'python':
return ApiStrGenerate.getPythonString(taskId, options)
case 'php':
return ApiStrGenerate.getPHPString(taskId, options)
case 'golang':
return ApiStrGenerate.getGolangString(taskId, options)
case 'java':
return ApiStrGenerate.getJaveString(taskId, options)
case 'rust':
return ApiStrGenerate.getRustString(taskId, options)
default:
return '// 请选择一种编程语言查看示例代码'
}
}
// 复制代码到剪贴板
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
// 这里可以添加成功提示
} catch (err) {
console.error('复制失败:', err)
}
}
return {
handleUpdateOpen,
activeTab,
codeLanguages,
generateApiCode,
copyToClipboard
}
}
})
</script>
<template>
<Dialog :open="open" @update:open="handleUpdateOpen">
<DialogContent class="w-[800px] sm:w-[900px] lg:w-[1000px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<span>API接口</span>
<Badge v-if="taskData" variant="outline">{{ taskData.name }}</Badge>
</DialogTitle>
</DialogHeader>
<div class="mt-6 space-y-4">
<!-- API 信息概览 -->
<div class="space-y-3">
<div class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<Badge variant="default">POST</Badge>
<code class="text-sm">/sendtasks/send</code>
</div>
<p class="text-sm text-gray-600">发送消息创建新的消息</p>
</div>
</div>
<!-- 代码示例 -->
<div class="space-y-4">
<h3 class="font-semibold">代码示例</h3>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-7 gap-1">
<TabsTrigger v-for="lang in codeLanguages" :key="lang.value" :value="lang.value"
class="flex items-center gap-1 px-2 py-1 text-xs">
<span>{{ lang.icon }}</span>
<span class="hidden sm:inline">{{ lang.label }}</span>
<span class="sm:hidden">{{ lang.label.slice(0, 3) }}</span>
</TabsTrigger>
</TabsList>
<TabsContent v-for="lang in codeLanguages" :key="lang.value" :value="lang.value" class="mt-4">
<div class="relative">
<Button size="sm" variant="outline" class="absolute top-2 right-2 z-10"
@click="copyToClipboard(generateApiCode(lang.value))">
复制代码
</Button>
<pre
class="bg-gray-900 text-gray-100 p-3 rounded-lg overflow-x-auto text-xs leading-tight max-w-full whitespace-pre-wrap break-words"><code class="text-xs font-mono">{{ generateApiCode(lang.value) }}</code></pre>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<style scoped>
/* 代码块样式优化 */
pre {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
</style>
@@ -0,0 +1,367 @@
<script setup lang="ts">
import { ref, computed, defineEmits, defineProps, withDefaults, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from "@/components/ui/badge"
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxList,
ComboboxItem,
ComboboxViewport
} from '@/components/ui/combobox'
import { CheckIcon } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Switch } from '@/components/ui/switch'
import { CONSTANT } from '@/constant'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
import { generateBizUniqueID } from '@/util/uuid'
// 组件props
interface Props {
open?: boolean
editData?: any // 编辑时传入的数据
}
const props = withDefaults(defineProps<Props>(), {
open: false,
editData: null
})
// 组件emits
const emit = defineEmits<{
'update:open': [value: boolean]
'save': [data: any]
}>()
// 前端的页面添加配置
const waysConfigMap = CONSTANT.WAYS_DATA
// 搜索相关状态
const searchQuery = ref('')
const isSearching = ref(false)
const channelName = ref('')
// 当前显示的选项(搜索结果或所有选项)
const displayOptions = ref<Array<{ id: string, name: string, type: string }>>([])
// 输入框显示值(只在用户搜索时显示搜索内容)
const inputDisplayValue = computed({
get: () => searchQuery.value,
set: (value: string) => {
searchQuery.value = value
}
})
// 当前选中任务的配置
const currentChannelConfig = computed(() => {
// 先根据label找到type
let type = displayOptions.value.find(item => item.name === channelName.value)?.type
// 再根据type找到配置
let rs = waysConfigMap.find(item => item.type === type) || null;
return rs;
})
// 表单数据
const formData = ref<Record<string, any>>({})
// 监听任务模式变化
const handlechannelNameChange = () => {
// 数据加载后,text/html单选设置默认选中(这里选第一个)
if (currentChannelConfig.value?.taskInsRadios.length > 0) {
formData.value.taskInsRadio = currentChannelConfig.value?.taskInsRadios[0].subLabel
}
}
// 关闭drawer
const handleClose = () => {
emit('update:open', false)
}
// 添加单条实例配置
const handleAddSubmit = async () => {
// 组建表单数据
let postData = {
"id": generateBizUniqueID('I'),
"enable": 1,
"task_id": props.editData.id,
"way_id": displayOptions.value[0]?.id,
"way_type": displayOptions.value[0]?.type,
"way_name": displayOptions.value[0]?.name,
"content_type": formData.value.taskInsRadio,
"config": "{}"
};
const { taskInsRadio, ...configValue } = formData.value
if (configValue) {
postData.config = JSON.stringify(configValue)
}
const rsp = await request.post('/sendtasks/ins/addone', postData);
if (await rsp.data.code == 200) {
// 动态添加表格第一行
const newItem = {
...postData,
created_by: '',
modified_by: '',
created_on: new Date().toISOString(),
modified_on: new Date().toISOString(),
extra: ''
};
insTableData.value.unshift(newItem);
toast.success(rsp.data.msg);
}
}
const insTableData = ref<Array<{
id: string;
created_by: string;
modified_by: string;
created_on: string;
modified_on: string;
task_id: string;
way_id: string;
way_type: string;
content_type: string;
config: string;
extra: string;
enable: number;
way_name: string;
}>>([]);
const queryInsListData = async () => {
let params = { id: props.editData.id };
const rsp = await request.get('/sendtasks/ins/gettask', { params: params });
insTableData.value = await rsp.data.data.ins_data;
}
// 防抖定时器
let searchTimer: number | null = null
// 搜索处理函数(带防抖)
const handleSearch = (query: string) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
if (!query.trim()) {
displayOptions.value = []
return
}
// 设置防抖延迟(500ms
searchTimer = window.setTimeout(async () => {
isSearching.value = true
try {
const results = await querySearchWayAsync(query)
displayOptions.value = results.map((item: any) => ({
id: item.id,
name: item.name,
type: item.type
}))
} catch (error) {
console.error('搜索失败:', error)
displayOptions.value = []
} finally {
isSearching.value = false
}
}, 500)
}
const querySearchWayAsync = async (query: string) => {
const params = { name: query }
const rsp = await request.get('/sendways/list', { params })
return rsp.data.data.lists
}
const getWayTypeText = (type: string) => {
const wayData = CONSTANT.WAYS_DATA.find(item => item.type === type)
return wayData ? wayData.label : type
}
// 切换启用状态
const toggleEnable = async (item: any) => {
const newStatus = item.enable === 1 ? 0 : 1
let postData = { ins_id: item.id, status: newStatus };
const rsp = await request.post('/sendtasks/ins/update_enable', postData);
if (await rsp.data.code == 200) {
toast.success(rsp.data.msg);
// 更新本地状态
item.enable = newStatus
}
}
const handleDelete = async (item: any) => {
const rsp = await request.post('/sendtasks/ins/delete', { id: item.id });
if (rsp.status == 200) {
toast.success(rsp.data.msg);
insTableData.value = insTableData.value.filter((ins) => ins.id !== item.id)
}
}
const handleEditTask = async () => {
let postData = { id: props.editData.id, name: props.editData.name };
const rsp = await request.post('/sendtasks/edit', postData);
if (await rsp.data.code == 200) {
toast.success(rsp.data.msg);
}
}
// 格式化额外信息列的值
const formatInsConfigDisplay = (row: any) => {
if (!row.config) {
return ""
}
if (["Email", "WeChatOFAccount"].includes(row.way_type)) {
let config = JSON.parse(row.config)
let info = `${config.to_account}`
return info
} else {
return ""
}
}
// 组件挂载时加载实例配置列表
onMounted(() => {
queryInsListData();
})
</script>
<template>
<div class="w-full">
<!-- 任务信息展示区域 -->
<div v-if="props.editData" class="flex flex-col sm:flex-row sm:items-center gap-2 border-b p-4">
<Label class="w-16 sm:w-16">任务名称</Label>
<Input v-model="props.editData.name" placeholder="请输入任务名称" class="w-full sm:w-64" />
<Button class="w-full sm:w-auto sm:ml-auto" @click="handleEditTask">修改</Button>
</div>
<div class="mt-4">
<div class="flex gap-4">
<div class="flex-1">
<Combobox v-model="channelName" @update:model-value="handlechannelNameChange">
<ComboboxAnchor class="w-full">
<ComboboxInput v-model="inputDisplayValue" @input="handleSearch(inputDisplayValue)"
class="flex h-10 w-full " placeholder="搜索或选择渠道类型进行实例的添加..." />
</ComboboxAnchor>
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
<ComboboxViewport>
<div v-if="isSearching" class="px-2 py-1.5 text-sm text-muted-foreground">
搜索中...
</div>
<div v-else-if="searchQuery && displayOptions.length === 0"
class="px-2 py-1.5 text-sm text-muted-foreground">
未找到匹配项
</div>
<ComboboxItem v-for="option in displayOptions" :key="option.id" :value="option.name"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<CheckIcon class="mr-2 h-4 w-4" :class="channelName === option.name ? 'opacity-100' : 'opacity-0'" />
{{ option.name }}
</ComboboxItem>
</ComboboxViewport>
</ComboboxList>
</Combobox>
</div>
</div>
<!-- 动态任务配置区域 -->
<div v-if="currentChannelConfig" class="mt-4">
<!-- 任务指令输入字段 -->
<div v-if="currentChannelConfig.taskInsInputs && currentChannelConfig.taskInsInputs.length > 0" class="mb-2">
<Label class="text-sm font-medium text-gray-700 mb-1">实例配置</Label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="input in currentChannelConfig.taskInsInputs" :key="input.col" class="space-y-2">
<label class="text-xs font-medium text-gray-600">{{ input.label }}</label>
<Input v-model="formData[input.col]" :placeholder="input.desc || `请输入${input.desc}`"
:type="input.type || 'text'" class="text-sm" />
</div>
</div>
</div>
<!-- 任务指令单选项 -->
<div v-if="currentChannelConfig.taskInsRadios && currentChannelConfig.taskInsRadios.length > 0">
<Label class="text-sm font-medium text-gray-700 mb-1">消息格式</Label>
<RadioGroup v-model="formData.taskInsRadio" class="flex flex-wrap gap-4">
<div v-for="radio in currentChannelConfig.taskInsRadios" :key="radio.value"
class="flex items-center space-x-2">
<RadioGroupItem :value="radio.subLabel" :id="radio.subLabel" />
<Label :for="radio.value"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{{ radio.subLabel }}
</Label>
</div>
</RadioGroup>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-2 border-b pb-4 mt-2">
<Button variant="outline" @click="handleClose">取消</Button>
<Button @click="handleAddSubmit">添加实例</Button>
</div>
<!-- 关联的实例表 -->
<div class="mt-4">
<h3 class="text-sm font-medium text-gray-900 mb-3">已经关联的实例</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>渠道名</TableHead>
<TableHead>渠道/内容</TableHead>
<TableHead class="text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="item in insTableData" :key="item.id">
<TableCell>{{ item.way_name }}</TableCell>
<TableCell>
<Badge variant="outline">
{{ getWayTypeText(item.way_type) }}
</Badge>
<Badge variant="outline">
{{ item.content_type }}
</Badge>
<Badge variant="outline" v-if="item.config !== '{}'">
{{ formatInsConfigDisplay(item) }}
</Badge>
</TableCell>
<TableCell class="text-center">
<div class="flex items-center justify-center gap-2">
<Switch :model-value="item.enable === 1" @update:model-value="() => toggleEnable(item)" />
<Button size="sm" variant="outline" class="text-red-500 border-red-300 hover:bg-red-50
hover:border-red-400 hover:text-red-600 hover:shadow-md
transition-all duration-200" @click="() => handleDelete(item)">删除</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="!insTableData || insTableData.length === 0">
<TableCell :colspan="3" class="h-24">
<EmptyTableState title="暂无实例" description="还没有配置任何实例请先添加" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'EditTasks'
})
</script>
@@ -0,0 +1,278 @@
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import Pagination from '@/components/ui/Pagination.vue'
import AddTasks from './AddTasks.vue'
import EditTasks from './EditTasks.vue'
import ApiCodeViewer from './ApiCodeViewer.vue'
import { toast } from 'vue-sonner'
import { useRoute, useRouter } from 'vue-router';
import { request } from '@/api/api';
import { CONSTANT } from '@/constant';
// @ts-ignore
import { getPageSize } from '@/util/pageUtils';
interface WayItem {
id: string
name: string
type: string
config: string
created_on: string
modified_on: string
status: number
}
const route = useRoute();
const router = useRouter();
let state = reactive({
tableData: [] as WayItem[],
total: 0,
currPage: 1,
pageSize: getPageSize(),
search: '',
optionValue: '',
})
// 状态过滤
const selectedStatus = ref('all')
// 任务类型过滤
const selectedChannelType = ref('all')
// Sheet 相关状态
const isSheetOpen = ref(false)
const selectedConfig = ref('')
const selectedChannelName = ref('')
// 新增任务 Sheet 状态
const isAddChannelDrawerOpen = ref(false)
// 编辑任务 Sheet 状态
const isEditChannelDrawerOpen = ref(false)
const editChannelData = ref<WayItem | null>(null)
// API代码查看器状态
const isApiCodeViewerOpen = ref(false)
const selectedTaskData = ref<Record<string, any> | undefined>(undefined)
// 处理保存新任务
const handleSaveChannel = (_data: any) => {
// 这里可以添加实际的保存逻辑
// 保存成功后刷新列表
queryListDataWithStatus()
}
// 总页数
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
// const getWayTypeText = (type: string) => {
// const wayItem = CONSTANT.WAYS_DATA.find(item => item.type === type)
// return wayItem ? wayItem.label : type
// }
// 打开编辑任务Drawer
const openEditChannelDrawer = (channel: WayItem) => {
editChannelData.value = channel
isEditChannelDrawerOpen.value = true
}
// 处理编辑任务保存
const handleEditChannel = (_data: any) => {
// 保存成功后刷新列表
queryListDataWithStatus()
}
// 处理查看日志
const handleViewLogs = (channel: WayItem) => {
// 跳转到发信日志页面,携带taskid参数
router.push(`/sendlogs?taskid=${channel.id}`)
}
// 处理查看API接口
const handleViewApi = (channel: WayItem) => {
selectedTaskData.value = channel
isApiCodeViewerOpen.value = true
}
const changePage = async (page: number) => {
if (page >= 1 && page <= totalPages.value) {
state.currPage = page
await queryListDataWithStatus()
}
}
//触发过滤筛选
const filterFunc = async () => {
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
}
// 按任务类型过滤
// const filterByChannelType = async (value: any) => {
// if (value) {
// selectedChannelType.value = String(value);
// state.currPage = 1; // 重置到第一页
// await queryListDataWithStatus();
// }
// }
// 查询数据(包含状态过滤)
const queryListDataWithStatus = async () => {
const statusParam = selectedStatus.value === 'all' ? '' : selectedStatus.value;
const channelTypeParam = selectedChannelType.value === 'all' ? '' : selectedChannelType.value;
await queryListData(state.currPage, state.pageSize, state.search, channelTypeParam, '', statusParam);
}
const queryListData = async (page: number, size: number, name = '', channelType = '', query = '', status = '') => {
let params: any = { page: page, size: size, name: name, type: channelType, query: query };
if (status !== '') {
params.status = status;
}
const rsp = await request.get('/sendtasks/list', { params: params });
state.tableData = await rsp.data.data.lists;
state.total = await rsp.data.data.total;
}
// 删除任务
const handleDelete = async (id: string) => {
const rsp = await request.post('/sendtasks/delete', { id: id });
if (rsp.status == 200 && await rsp.data.code == 200) {
// state.tableData.splice(index, 1);
toast.success(rsp.data.msg);
setTimeout(() => {
window.location.reload();
}, 1000);
}
}
onMounted(async () => {
// 初始化查询
state.search = route.query.name?.toString() || '';
await queryListData(
1,
state.pageSize,
route.query.name?.toString() || '',
route.query.channel_type?.toString() || ''
);
});
</script>
<template>
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
<div class="flex-1 sm:flex-initial">
<Input v-model="state.search" placeholder="搜索发信方式名称..." class="w-full sm:w-64" @keyup.enter="filterFunc"
@blur="filterFunc" />
</div>
</div>
<div class="flex-shrink-0">
<Dialog v-model:open="isAddChannelDrawerOpen">
<DialogTrigger as-child>
<Button variant="default" class="w-full sm:w-auto bg-gray-800 hover:bg-gray-900">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
新增任务
</Button>
</DialogTrigger>
<DialogContent class="w-[500px] max-w-[90vw]">
<DialogHeader>
<DialogTitle>新增发信任务</DialogTitle>
</DialogHeader>
<div class="px-4 pb-4">
<AddTasks v-model:open="isAddChannelDrawerOpen" @save="handleSaveChannel"
@cancel="() => isAddChannelDrawerOpen = false" />
</div>
</DialogContent>
</Dialog>
</div>
</div>
<!-- 表格 -->
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-20">ID</TableHead>
<TableHead>发信任务名称</TableHead>
<TableHead>创建时间</TableHead>
<TableHead>更新时间</TableHead>
<TableHead class="text-center">操作/状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- 空数据展示 -->
<TableRow v-if="state.tableData.length === 0">
<TableCell colspan="6" class="text-center py-12">
<EmptyTableState title="暂无发信方式" description="还没有配置任何发信方式,请先添加发信方式" />
</TableCell>
</TableRow>
<!-- 数据行 -->
<TableRow v-for="channel in state.tableData" :key="channel.id">
<TableCell>{{ channel.id }}</TableCell>
<TableCell>{{ channel.name }}</TableCell>
<TableCell>{{ channel.created_on }}</TableCell>
<TableCell>{{ channel.modified_on }}</TableCell>
<TableCell class="text-center space-x-2" v-if="channel.id !== CONSTANT.LOG_TASK_ID">
<Button size="sm" variant="outline" @click="handleViewApi(channel)">接口</Button>
<Button size="sm" variant="outline" @click="handleViewLogs(channel)">日志</Button>
<Button size="sm" variant="outline" @click="openEditChannelDrawer(channel)">编辑</Button>
<Button size="sm" variant="outline" class="text-red-500 border-red-300 hover:bg-red-50
hover:border-red-400 hover:text-red-600 hover:shadow-md
transition-all duration-200" @click="handleDelete(channel.id)">删除</Button>
</TableCell>
<TableCell class="text-center space-x-2" v-else>
<Button size="sm" variant="outline" @click="handleViewLogs(channel)">日志</Button>
<label>系统保留任务</label>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 分页 -->
<Pagination :total="state.total" :current-page="state.currPage" :page-size="state.pageSize"
@page-change="changePage" />
<!-- 编辑任务Dialog -->
<Dialog v-model:open="isEditChannelDrawerOpen">
<DialogContent class="w-[500px] max-w-[90vw] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader class="flex-shrink-0">
<DialogTitle>编辑发信任务</DialogTitle>
</DialogHeader>
<div class="px-4 pb-4 flex-1 overflow-y-auto">
<EditTasks v-model:open="isEditChannelDrawerOpen" :edit-data="editChannelData" @save="handleEditChannel" />
</div>
</DialogContent>
</Dialog>
<!-- 配置详情Sheet -->
<Sheet v-model:open="isSheetOpen">
<SheetContent class="w-[600px] sm:w-[900px] lg:w-[1000px]">
<SheetHeader>
<SheetTitle>{{ selectedChannelName }} - 发信方式配置详情</SheetTitle>
</SheetHeader>
<div class="mt-6">
<div class="bg-gray-50 p-4 rounded-lg">
<pre class="whitespace-pre-wrap text-sm font-mono">{{ selectedConfig }}</pre>
</div>
</div>
</SheetContent>
</Sheet>
<!-- API代码查看器 -->
<ApiCodeViewer v-model:open="isApiCodeViewerOpen" :task-data="selectedTaskData" />
</div>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { defineEmits, defineProps, withDefaults } from 'vue'
import WaysForm from './WaysForm.vue'
// 组件props
interface Props {
open?: boolean
}
withDefaults(defineProps<Props>(), {
open: false
})
// 组件emits
const emit = defineEmits<{
'update:open': [value: boolean]
'save': [data: any]
}>()
// 处理保存事件
const handleSave = (data: any) => {
emit('save', data)
}
// 处理关闭事件
const handleUpdateOpen = (value: boolean) => {
emit('update:open', value)
}
</script>
<template>
<WaysForm
:open="open"
mode="add"
@update:open="handleUpdateOpen"
@save="handleSave"
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AddWays'
})
</script>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { defineEmits, defineProps, withDefaults } from 'vue'
import WaysForm from './WaysForm.vue'
// 组件props
interface Props {
open?: boolean
editData?: any // 编辑时传入的数据
}
withDefaults(defineProps<Props>(), {
open: false,
editData: null
})
// 组件emits
const emit = defineEmits<{
'update:open': [value: boolean]
'save': [data: any]
}>()
// 处理保存事件
const handleSave = (data: any) => {
emit('save', data)
}
// 处理关闭事件
const handleUpdateOpen = (value: boolean) => {
emit('update:open', value)
}
</script>
<template>
<WaysForm
:open="open"
:edit-data="editData"
mode="edit"
@update:open="handleUpdateOpen"
@save="handleSave"
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'EditWays'
})
</script>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import Pagination from '@/components/ui/Pagination.vue'
import AddWays from './AddWays.vue'
import EditWays from './EditWays.vue'
import { toast } from 'vue-sonner'
import { useRoute } from 'vue-router';
import { request } from '@/api/api';
import { CONSTANT } from '@/constant';
// @ts-ignore
import { getPageSize } from '@/util/pageUtils';
interface WayItem {
id: number
name: string
type: string
config: string
created_on: string
modified_on: string
status: number
}
const router = useRoute();
let state = reactive({
tableData: [] as WayItem[],
total: 0,
currPage: 1,
pageSize: getPageSize(),
search: '',
optionValue: '',
})
// 状态过滤
const selectedStatus = ref('all')
// 渠道类型过滤
const selectedChannelType = ref('all')
// 渠道类型选项 - 从CONSTANT.WAYS_DATA生成
const channelTypeOptions = computed(() => {
const options = [{ value: 'all', label: '全部类型' }]
CONSTANT.WAYS_DATA.forEach(item => {
options.push({ value: item.type, label: item.label })
})
return options
})
// Sheet 相关状态
const isSheetOpen = ref(false)
const selectedConfig = ref('')
const selectedChannelName = ref('')
// 新增渠道 Sheet 状态
const isAddChannelDrawerOpen = ref(false)
// 编辑渠道 Sheet 状态
const isEditChannelDrawerOpen = ref(false)
const editChannelData = ref<WayItem | null>(null)
// 处理保存新渠道
const handleSaveChannel = (_data: any) => {
// 这里可以添加实际的保存逻辑
// 保存成功后刷新列表
queryListDataWithStatus()
}
// 总页数
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
const getWayTypeText = (type: string) => {
const wayData = CONSTANT.WAYS_DATA.find(item => item.type === type)
return wayData ? wayData.label : type
}
// 打开编辑渠道Drawer
const openEditChannelDrawer = (channel: WayItem) => {
editChannelData.value = channel
isEditChannelDrawerOpen.value = true
}
// 处理编辑渠道保存
const handleEditChannel = (_data: any) => {
// 保存成功后刷新列表
queryListDataWithStatus()
}
const changePage = async (page: number) => {
if (page >= 1 && page <= totalPages.value) {
state.currPage = page
await queryListDataWithStatus()
}
}
//触发过滤筛选
const filterFunc = async () => {
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
}
// 按渠道类型过滤
const filterByChannelType = async (value: any) => {
if (value) {
selectedChannelType.value = String(value);
state.currPage = 1; // 重置到第一页
await queryListDataWithStatus();
}
}
// 查询数据(包含状态过滤)
const queryListDataWithStatus = async () => {
const statusParam = selectedStatus.value === 'all' ? '' : selectedStatus.value;
const channelTypeParam = selectedChannelType.value === 'all' ? '' : selectedChannelType.value;
await queryListData(state.currPage, state.pageSize, state.search, channelTypeParam, '', statusParam);
}
const queryListData = async (page: number, size: number, name = '', channelType = '', query = '', status = '') => {
let params: any = { page: page, size: size, name: name, type: channelType, query: query };
if (status !== '') {
params.status = status;
}
const rsp = await request.get('/sendways/list', { params: params });
state.tableData = await rsp.data.data.lists;
state.total = await rsp.data.data.total;
}
// 删除渠道
const handleDelete = async (id: number) => {
const rsp = await request.post('/sendways/delete', { id: id });
if (rsp.status == 200 && await rsp.data.code == 200) {
// state.tableData.splice(index, 1);
toast.success(rsp.data.msg);
setTimeout(() => {
window.location.reload();
}, 1000);
}
}
onMounted(async () => {
// 初始化查询
state.search = router.query.name?.toString() || '';
await queryListData(
1,
state.pageSize,
router.query.name?.toString() || '',
router.query.channel_type?.toString() || ''
);
});
</script>
<template>
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
<div class="flex-1 sm:flex-initial">
<Input v-model="state.search" placeholder="搜索发信方式名称..." class="w-full sm:w-64" @keyup.enter="filterFunc"
@blur="filterFunc" />
</div>
<div class="flex-1 sm:flex-initial">
<Select v-model="selectedChannelType" class="w-full" @update:model-value="filterByChannelType">
<SelectTrigger>
<SelectValue placeholder="选择渠道类型" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="option in channelTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div class="flex-shrink-0">
<Drawer v-model:open="isAddChannelDrawerOpen">
<DrawerTrigger as-child>
<Button variant="default" class="w-full sm:w-auto bg-gray-800 hover:bg-gray-900">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
新增渠道
</Button>
</DrawerTrigger>
<DrawerContent class="w-[800px] max-w-[90vw] mx-auto h-[90vh] max-h-[90vh]">
<DrawerHeader>
<DrawerTitle>新增发信渠道</DrawerTitle>
</DrawerHeader>
<div class="px-4 pb-4 overflow-y-auto">
<AddWays v-model:open="isAddChannelDrawerOpen" @save="handleSaveChannel" />
</div>
</DrawerContent>
</Drawer>
</div>
</div>
<!-- 表格 -->
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-20">ID</TableHead>
<TableHead>发信方式名称</TableHead>
<TableHead>发信方式类型</TableHead>
<TableHead>创建时间</TableHead>
<TableHead>更新时间</TableHead>
<TableHead class="text-center">操作/状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- 空数据展示 -->
<TableRow v-if="state.tableData.length === 0">
<TableCell colspan="6" class="text-center py-12">
<EmptyTableState title="暂无发信方式" description="还没有配置任何发信方式,请先添加发信方式" />
</TableCell>
</TableRow>
<!-- 数据行 -->
<TableRow v-for="channel in state.tableData" :key="channel.id">
<TableCell>{{ channel.id }}</TableCell>
<TableCell>{{ channel.name }}</TableCell>
<TableCell>
<Badge variant="outline">{{ getWayTypeText(channel.type) }}</Badge>
</TableCell>
<TableCell>{{ channel.created_on }}</TableCell>
<TableCell>{{ channel.modified_on }}</TableCell>
<TableCell class="text-center space-x-2">
<Button size="sm" variant="outline" @click="openEditChannelDrawer(channel)">编辑</Button>
<!-- <Button size="sm" variant="outline" @click="openConfigSheet(channel)">查看</Button> -->
<Button size="sm" variant="outline" class="text-red-500 border-red-300 hover:bg-red-50
hover:border-red-400 hover:text-red-600 hover:shadow-md
transition-all duration-200" @click="handleDelete(channel.id)">删除</Button>
<!-- <Badge :class="channel.status === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'">
{{ getStatusText(channel.status) }} -->
<!-- </Badge> -->
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 分页 -->
<Pagination :total="state.total" :current-page="state.currPage" :page-size="state.pageSize"
@page-change="changePage" />
<!-- 编辑渠道Drawer -->
<Drawer v-model:open="isEditChannelDrawerOpen">
<DrawerContent class="w-[800px] max-w-[90vw] mx-auto h-[90vh] max-h-[90vh]">
<DrawerHeader>
<DrawerTitle>编辑发信渠道</DrawerTitle>
</DrawerHeader>
<div class="px-4 pb-4 overflow-y-auto">
<EditWays v-model:open="isEditChannelDrawerOpen" :edit-data="editChannelData" @save="handleEditChannel" />
</div>
</DrawerContent>
</Drawer>
<!-- 配置详情Sheet -->
<Sheet v-model:open="isSheetOpen">
<SheetContent class="w-[600px] sm:w-[900px] lg:w-[1000px]">
<SheetHeader>
<SheetTitle>{{ selectedChannelName }} - 发信方式配置详情</SheetTitle>
</SheetHeader>
<div class="mt-6">
<div class="bg-gray-50 p-4 rounded-lg">
<pre class="whitespace-pre-wrap text-sm font-mono">{{ selectedConfig }}</pre>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</template>
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { ref, computed, defineEmits, defineProps, withDefaults, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { CONSTANT } from '@/constant'
import { validateForm, createValidationState, type InputConfig } from '@/util/validation'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip'
// 组件props
interface Props {
open?: boolean
editData?: any // 编辑时传入的数据
mode?: 'add' | 'edit' // 模式:新增或编辑
}
const props = withDefaults(defineProps<Props>(), {
open: false,
editData: null,
mode: 'add'
})
// 组件emits
const emit = defineEmits<{
'update:open': [value: boolean]
'save': [data: any]
}>()
// 前端的页面添加配置
let waysConfigMap = CONSTANT.WAYS_DATA;
// Radio Group 选项 - 根据waysConfigMap动态生成
const channelModeOptions = waysConfigMap.map(item => ({
value: item.type,
label: item.label
}))
const channelMode = ref(channelModeOptions[0]?.value || '')
// 当前选中渠道的配置
const currentChannelConfig = computed(() => {
return waysConfigMap.find(item => item.type === channelMode.value) || null
})
// 表单数据
const formData = ref<Record<string, any>>({})
// 校验状态管理
const validationState = createValidationState()
// 初始化表单数据
const initFormData = () => {
const config = currentChannelConfig.value
if (!config) return
const newFormData: Record<string, any> = {}
// 如果是编辑模式且有编辑数据,先填充编辑数据
if (props.mode === 'edit' && props.editData) {
// 设置渠道类型
channelMode.value = props.editData.type || channelModeOptions[0]?.value || ''
// 解析auth数据
let authData: Record<string, any> = {}
try {
authData = props.editData.auth ? JSON.parse(props.editData.auth) : {}
} catch (e) {
console.error('解析auth数据失败:', e)
}
// 填充基本字段
newFormData.name = props.editData.name || ''
// 填充auth中的字段
Object.keys(authData).forEach(key => {
newFormData[key] = authData[key]
})
}
// 初始化基本输入字段
if (config.inputs) {
config.inputs.forEach((input: any) => {
if (newFormData[input.col] === undefined) {
newFormData[input.col] = input.value || ''
}
})
}
// 初始化任务指令输入字段
if (config.taskInsInputs) {
config.taskInsInputs.forEach((input: any) => {
if (newFormData[input.col] === undefined) {
newFormData[input.col] = input.value || ''
}
})
}
// 初始化任务指令单选项
if (config.taskInsRadios && config.taskInsRadios.length > 0) {
if (newFormData.taskInsRadio === undefined) {
newFormData.taskInsRadio = config.taskInsRadios[0].value
}
}
formData.value = newFormData
}
// 获取所有输入字段配置
const getAllInputConfigs = (): InputConfig[] => {
const config = currentChannelConfig.value
if (!config) return []
const configs: InputConfig[] = []
// 基本输入字段
if (config.inputs) {
configs.push(...config.inputs.map((input: any) => ({
col: input.col,
label: input.label,
subLabel: input.subLabel,
type: input.type,
required: input.required !== false,
minLength: input.minLength,
maxLength: input.maxLength
})))
}
// 任务指令输入字段
if (config.taskInsInputs) {
configs.push(...config.taskInsInputs.map((input: any) => ({
col: input.col,
label: input.label,
subLabel: input.subLabel,
type: input.type,
required: input.required !== false,
minLength: input.minLength,
maxLength: input.maxLength
})))
}
return configs
}
// 校验表单
const validateFormData = () => {
const inputConfigs = getAllInputConfigs()
const result = validateForm(formData.value, inputConfigs)
validationState.setErrors(result.errors)
return result.isValid
}
// 监听渠道模式变化
const handleChannelModeChange = () => {
initFormData()
validationState.clearAllErrors()
}
// 监听编辑数据变化(仅编辑模式)
watch(() => props.editData, () => {
if (props.mode === 'edit') {
initFormData()
}
}, { immediate: true })
// 初始化表单数据(新增模式)
if (props.mode === 'add') {
initFormData()
}
// 关闭drawer
const handleClose = () => {
emit('update:open', false)
}
// 获取最终提交数据
const getFinalData = () => {
// 根据当前渠道配置的inputs中的col字段,从formData中提取对应的值组成auth对象
const config = currentChannelConfig.value
const authData: Record<string, any> = {}
if (config && config.inputs) {
config.inputs.forEach((input: any) => {
if (formData.value[input.col] !== undefined && input.col != 'name') {
authData[input.col] = formData.value[input.col]
if (config.type == 'Email' && input.col == 'port') {
authData[input.col] = parseInt(formData.value[input.col])
}
}
})
}
let postData: Record<string, any> = {
auth: JSON.stringify(authData),
type: channelMode.value,
name: formData.value.name,
}
// 编辑时需要传递ID
if (props.mode === 'edit' && props.editData && props.editData.id) {
postData.id = props.editData.id
}
return postData
}
// 测试连接
const handleTest = async () => {
if (!validateFormData()) { return }
let postData = getFinalData()
const rsp = await request.post('/sendways/test', postData)
if (await rsp.data.code == 200) {
toast({ message: await rsp.data.msg, type: 'success' })
}
}
// 保存数据
const handleSave = async () => {
if (!validateFormData()) { return }
let postData = getFinalData()
// 根据模式选择API路径和成功消息
const apiUrl = props.mode === 'edit' ? '/sendways/edit' : '/sendways/add'
const successMessage = props.mode === 'edit' ? '更新渠道成功!' : '添加渠道成功!'
const rsp = await request.post(apiUrl, postData);
if (await rsp.data.code == 200) {
toast(successMessage )
setTimeout(() => {
window.location.reload();
}, 1000);
}
}
// 计算保存按钮文本
const saveButtonText = computed(() => {
return props.mode === 'edit' ? '更新' : '保存'
})
</script>
<template>
<div class="w-full">
<!-- Radio Group -->
<div class="mb-6">
<label class="text-lg font-medium mb-3 block">渠道类型</label>
<RadioGroup v-model="channelMode" @update:model-value="handleChannelModeChange" class="flex flex-wrap gap-4">
<div v-for="option in channelModeOptions" :key="option.value" class="flex items-center space-x-2">
<RadioGroupItem :value="option.value" :id="option.value" />
<label :for="option.value"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{{ option.label }}
</label>
</div>
</RadioGroup>
</div>
<div class="w-full">
<!-- 动态表单 -->
<div v-if="currentChannelConfig" class="mt-6">
<!-- 基本配置输入字段 -->
<div v-if="currentChannelConfig.inputs && currentChannelConfig.inputs.length > 0" class="mb-8">
<h4 class="text-base font-medium mb-4 text-gray-800">基本配置</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="input in currentChannelConfig.inputs" :key="input.col" class="space-y-2" :class="{
'md:col-span-2': input.isTextArea
}">
<Label :for="input.col" class="text-sm font-medium">
{{ input.subLabel || input.label }}
<span v-if="input.tips" class="text-xs text-gray-500 ml-1">({{ input.tips }})</span>
</Label>
<!-- 配置输入框 -->
<Textarea v-if="input.isTextArea" :id="input.col" v-model="formData[input.col]"
:placeholder="input.desc || input.placeholder || input.subLabel || input.label" :class="{
'w-full': true,
'border-red-500 focus:border-red-500': validationState.errors.value[input.col]
}" @input="() => validationState.clearFieldError(input.col)" />
<Input v-else :id="input.col" v-model="formData[input.col]"
:placeholder="input.desc || input.placeholder || input.subLabel || input.label" :class="{
'w-full': true,
'border-red-500 focus:border-red-500': validationState.errors.value[input.col]
}" @input="() => validationState.clearFieldError(input.col)" />
<!-- 异常校验提示显示 -->
<div v-if="validationState.errors.value[input.col]" class="text-red-500 text-xs mt-1">
{{ validationState.errors.value[input.col] }}
</div>
</div>
</div>
<div class="mt-2 ml-4" v-if="currentChannelConfig.tips && currentChannelConfig.tips.text">
<TooltipProvider>
<Tooltip>
<TooltipTrigger class="text-sm hover:text-gray-700 inline-flex items-center gap-1">
{{ currentChannelConfig.tips.text }}
<span
class="cursor-help inline-flex items-center justify-center w-4 h-4 rounded-full border border-gray-300 hover:border-gray-400 text-xs">?</span>
</TooltipTrigger>
<TooltipContent>
<p class="text-sm">{{ currentChannelConfig.tips.desc }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
<div v-else class="mt-6 p-6 bg-gray-50 rounded-lg">
<p class="text-gray-500">请选择一个渠道类型开始配置</p>
</div>
</div>
<div class="flex justify-end gap-2 mt-8 pt-4 border-t">
<Button variant="outline" @click="handleClose">取消</Button>
<Button @click="handleTest">测试</Button>
<Button @click="handleSave">{{ saveButtonText }}</Button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'WaysForm'
})
</script>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { Github, FileText } from 'lucide-vue-next'
import { request } from '@/api/api'
import { toast } from 'vue-sonner'
const state = reactive({
version: '1.0.0',
description: '一个现代化的消息推送管理平台,支持多种推送渠道和灵活的消息管理功能。',
features: [
'多渠道消息推送',
'定时消息管理',
'托管消息服务',
'发信日志追踪',
'渠道配置管理'
],
techStack: ['Vue 3', 'TypeScript', 'Vite', 'Tailwind CSS', 'Shadcn/ui'],
githubUrl: 'https://github.com/engigu/Message-Push-Nest',
copyright: '保留所有权利.',
versionLog: ''
})
// 获取关于页面配置
const getAboutConfig = async () => {
try {
const params = { params: { section: 'about' } }
const response = await request.get('/settings/getsetting', params)
if (response.data.code === 200) {
const data = response.data.data
if (data.version) state.version = data.version
if (data.desc) state.versionLog = data.desc
}
} catch (error) {
toast.error('获取关于信息失败')
}
}
onMounted(() => {
getAboutConfig()
})
</script>
<script lang="ts">
export default {
name: 'AboutSettings'
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>站点关于</CardTitle>
<CardDescription>{{ state.description }}</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<h3 class="font-medium text-gray-900 mb-2">技术栈</h3>
<div class="flex flex-wrap gap-2">
<Badge v-for="tech in state.techStack" :key="tech">{{ tech }}</Badge>
</div>
</div>
<div>
<h3 class="font-medium text-gray-900 mb-2">功能特性</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li v-for="feature in state.features" :key="feature"> {{ feature }}</li>
</ul>
</div>
</div>
<div class="space-y-4">
<div>
<h3 class="font-medium text-gray-900 mb-2">系统信息</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">版本:</span>
<Badge variant="outline">{{ state.version }}</Badge>
</div>
<div class="flex justify-between">
<span class="text-gray-600">运行环境:</span>
<span>Vue 3 + TypeScript</span>
</div>
</div>
</div>
<div>
<h3 class="font-medium text-gray-900 mb-2">版本日志</h3>
<Sheet>
<SheetTrigger as-child>
<Button variant="outline" size="sm" class="inline-flex items-center gap-2">
<FileText class="w-4 h-4" />
查看更新日志
</Button>
</SheetTrigger>
<SheetContent class="w-[600px] sm:w-[800px]">
<SheetHeader>
<SheetTitle>版本更新日志</SheetTitle>
</SheetHeader>
<div class="mt-6">
<div class="bg-gray-50 p-4 rounded-lg">
<pre class="whitespace-pre-wrap text-sm font-mono">{{ state.versionLog }}</pre>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
<div class="border-t border-gray-200 my-4"></div>
<div class="text-center text-sm text-gray-500">
<p>© {{ new Date().getFullYear() }} {{ state.copyright }}</p>
<p class="mt-1">如有问题请联系系统管理员</p>
<p class="mt-2">
<a :href="state.githubUrl" target="_blank" class="inline-flex items-center gap-1 text-blue-500 hover:text-blue-700 underline">
<Github class="w-4 h-4" />
GitHub 仓库
</a>
</p>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
import { useRouter } from 'vue-router'
import { CONSTANT } from '@/constant'
import { HelpCircleIcon } from 'lucide-vue-next'
const router = useRouter()
const state = reactive({
section: 'log_config',
cron: '',
keepNum: '1000',
})
// 提交配置
const handleSubmit = async () => {
try {
const postData = {
section: state.section,
data: {
cron: state.cron.trim(),
keep_num: state.keepNum.trim(),
},
}
const response = await request.post('/settings/set', postData)
if (response.data.code === 200) {
const msg = response.data.msg
toast.success(msg)
}
} catch (error) {
toast.error('保存失败,请稍后重试')
}
}
// 查看日志
const handleView = async () => {
router.push({ path: '/sendlogs', query: { taskid: CONSTANT.LOG_TASK_ID } })
}
// 获取站点配置
const getSiteConfig = async () => {
try {
const params = { params: { section: 'log_config' } }
const response = await request.get('/settings/getsetting', params)
if (response.data.code === 200) {
const data = response.data.data
state.cron = data.cron || ''
state.keepNum = data.keep_num || '1000'
}
} catch (error) {
toast.error('获取配置失败')
}
}
onMounted(() => {
getSiteConfig()
})
</script>
<script lang="ts">
export default {
name: 'LogsSettings'
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>日志设置</CardTitle>
<CardDescription>配置定时日志清除和保留策略</CardDescription>
</CardHeader>
<CardContent>
<div class="setting-container">
<div class="space-y-4">
<!-- Cron表达式输入 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">定时清除Cron表达式</label>
<div class="flex">
<!-- <div class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-50 border border-r-0 border-gray-300 rounded-l-md">
cron://
</div> -->
<Input
v-model="state.cron"
placeholder="请输入定时日志清除的Cron表达式"
/>
</div>
</div>
<!-- 保留数量输入 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">保留日志条数</label>
<div class="flex">
<!-- <div class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-50 border border-r-0 border-gray-300 rounded-l-md">
保留数
</div> -->
<Input
v-model="state.keepNum"
placeholder="请输入要保留的最近的日志条数"
/>
</div>
</div>
</div>
<!-- 底部操作区域 -->
<div class="flex items-center justify-between mt-6">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">说明</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<HelpCircleIcon class="w-4 h-4 text-gray-400 hover:text-gray-600" />
</TooltipTrigger>
<TooltipContent class="max-w-xs">
<div class="text-sm">
<p>cron如果不设置默认是在每天的0点1分进行清理</p>
<p class="mt-1">保留数目如果不设置默认保留最近1000条</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="flex space-x-2">
<Button variant="outline" size="sm" @click="handleView">
查看日志
</Button>
<Button size="sm" @click="handleSubmit">
确定
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
// 重置密码相关
const passwordForm = ref({
newPassword: '',
currentPassword: ''
})
// 重置密码
const resetPassword = async () => {
// 检查演示模式
// @ts-ignore
const isDemoMode = (import.meta as any).env.VITE_RUN_MODE === 'demo'
if (isDemoMode) {
toast.error('演示模式下无法重置密码')
return
}
try {
let postData = { new_passwd: passwordForm.value.newPassword , old_passwd: passwordForm.value.currentPassword}
const rsp = await request.post('/settings/setpasswd', postData)
if (rsp.data.code == 200) {
let msg = rsp.data.msg
toast.success(msg)
} else {
toast.error(rsp.data.msg || '密码重置失败')
}
} catch (error) {
toast.error('密码重置失败,请稍后重试')
}
}
</script>
<script lang="ts">
export default {
name: 'PasswordSettings'
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>重置密码</CardTitle>
<CardDescription>更改您的登录密码</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="current-password">旧密码</Label>
<Input
id="current-password"
type="password"
v-model="passwordForm.currentPassword"
placeholder="请输入旧密码"
/>
</div>
<div class="space-y-2">
<Label for="new-password">新密码</Label>
<Input
id="new-password"
type="password"
v-model="passwordForm.newPassword"
placeholder="请输入新密码"
/>
</div>
<Button @click="resetPassword" class="w-full sm:w-auto">
重置密码
</Button>
</CardContent>
</Card>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
import SettingsSidebar from './SettingsSidebar.vue'
import PasswordSettings from './PasswordSettings.vue'
import LogsSettings from './LogsSettings.vue'
import SiteSettings from './SiteSettings.vue'
import AboutSettings from './AboutSettings.vue'
// 当前选中的设置项
const activeTab = ref('password')
</script>
<template>
<div class="p-6 w-full max-w-6xl mx-auto">
<div class="flex flex-col lg:flex-row gap-6">
<!-- 侧边栏 -->
<SettingsSidebar
:active-tab="activeTab"
@update:active-tab="activeTab = $event"
/>
<!-- 主内容区域 -->
<div class="flex-1">
<!-- 重置密码 -->
<PasswordSettings v-if="activeTab === 'password'" />
<!-- 日志清理 -->
<LogsSettings v-if="activeTab === 'logs'" />
<!-- 站点设置 -->
<SiteSettings v-if="activeTab === 'site'" />
<!-- 站点关于 -->
<AboutSettings v-if="activeTab === 'about'" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { KeyIcon, TrashIcon, SettingsIcon, InfoIcon } from 'lucide-vue-next'
// 定义props
interface Props {
activeTab: string
}
// 定义emits
interface Emits {
(e: 'update:activeTab', value: string): void
}
defineProps<Props>()
defineEmits<Emits>()
// 设置菜单项
const settingsMenu = [
{ id: 'password', name: '重置密码', icon: KeyIcon },
{ id: 'logs', name: '日志清理', icon: TrashIcon },
{ id: 'site', name: '站点设置', icon: SettingsIcon },
{ id: 'about', name: '站点关于', icon: InfoIcon }
]
</script>
<script lang="ts">
export default {
name: 'SettingsSidebar'
}
</script>
<template>
<div class="lg:w-64 flex-shrink-0">
<Card>
<CardHeader>
<CardTitle class="text-lg">设置</CardTitle>
<CardDescription>管理系统配置和偏好</CardDescription>
</CardHeader>
<CardContent class="p-0">
<nav class="space-y-1">
<button
v-for="item in settingsMenu"
:key="item.id"
@click="$emit('update:activeTab', item.id)"
:class="[
'w-full flex items-center px-4 py-3 text-left text-sm font-medium transition-colors',
activeTab === item.id
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
]"
>
<component :is="item.icon" class="mr-3 w-5 h-5" />
{{ item.name }}
</button>
</nav>
</CardContent>
</Card>
</div>
</template>
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
// @ts-ignore
import { LocalStieConfigUtils } from '@/util/localSiteConfig'
import { HelpCircleIcon } from 'lucide-vue-next'
const state = reactive({
title: '',
slogan: '',
logo: '',
pagesize: '',
section: 'site_config',
})
// 提交设置
const handleSubmit = async () => {
try {
const postData = {
section: state.section,
data: {
title: state.title.trim(),
slogan: state.slogan.trim(),
logo: state.logo.trim(),
pagesize: state.pagesize,
},
}
const response = await request.post('/settings/set', postData)
if (response.data.code === 200) {
const msg = response.data.msg
toast.success(msg)
}
} catch (error) {
toast.error('保存失败,请稍后重试')
}
}
// 恢复默认设置
const handleSubmitReset = async () => {
try {
const response = await request.post('/settings/reset', {})
if (response.data.code === 200) {
const msg = response.data.msg
toast.success(msg)
// 重新获取设置
await getSiteConfig()
}
} catch (error) {
toast.error('恢复默认设置失败,请稍后重试')
}
}
// 获取站点配置
const getSiteConfig = async () => {
try {
const params = { params: { section: 'site_config' } }
const response = await request.get('/settings/getsetting', params)
if (response.data.code === 200) {
const data = response.data.data
state.title = data.title || ''
state.logo = data.logo || ''
state.slogan = data.slogan || ''
state.pagesize = data.pagesize || ''
LocalStieConfigUtils.updateLocalConfig(data)
}
} catch (error) {
toast.error('获取配置失败')
}
}
onMounted(() => {
getSiteConfig()
})
</script>
<script lang="ts">
export default {
name: 'SiteSettings'
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>站点设置</CardTitle>
<CardDescription>配置站点基本信息和参数</CardDescription>
</CardHeader>
<CardContent>
<div class="setting-container">
<div class="space-y-4">
<!-- 站点标题 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">站点标题</label>
<Input v-model="state.title" placeholder="请输入自定义的网站标题" />
</div>
<!-- 站点标语 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">站点标语</label>
<Input v-model="state.slogan" placeholder="请输入自定义的网站slogan" />
</div>
<!-- 站点图标 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">站点图标</label>
<Input v-model="state.logo" placeholder="请输入自定义的网站logosvg文本)" />
<!-- SVG预览 -->
<div v-if="state.logo" class="mt-2 p-3 border border-gray-200 rounded-md bg-gray-50">
<div class="text-xs text-gray-500 mb-2">预览效果</div>
<div class="flex items-center justify-center w-16 h-16 bg-white border border-gray-300 rounded"
v-html="state.logo"></div>
</div>
</div>
<!-- 分页大小 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">分页大小</label>
<Input v-model="state.pagesize" placeholder="页面分页大小" />
</div>
</div>
<!-- 底部操作区域 -->
<div class="flex items-center justify-between mt-6">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">说明</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<HelpCircleIcon class="w-4 h-4 text-gray-400 hover:text-gray-600" />
</TooltipTrigger>
<TooltipContent class="max-w-sm">
<div class="text-sm space-y-1">
<p>1. logo请输入svg文本替换后登录页面ico导航栏logo将全部一起更换</p>
<p>2. slogan将在登录页面展示</p>
<p>** 将在下一次登录的时候生效如果不生效请在登录页面Ctrl+F5强制刷新</p>
<p>** logo将替换网页ico登录页面logo导航栏logo</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="flex space-x-2">
<Button variant="outline" size="sm" @click="handleSubmitReset">
恢复默认
</Button>
<Button size="sm" @click="handleSubmit">
确定
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
<style scoped>
.setting-container {
max-width: 500px;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col items-center justify-center space-y-3">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
<slot name="icon">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2M4 13h2m13-8V4a1 1 0 00-1-1H6a1 1 0 00-1 1v1m16 0V4a1 1 0 00-1-1H6a1 1 0 00-1 1v1"></path>
</svg>
</slot>
</div>
<div class="text-gray-500 text-center">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm">{{ description }}</p>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'EmptyTableState',
props: {
title: {
type: String,
default: '暂无数据'
},
description: {
type: String,
default: '当前没有任何数据'
}
}
})
</script>
+43
View File
@@ -0,0 +1,43 @@
<template>
<div class="flex items-center justify-between space-y-6">
<div class="text-sm text-gray-500">
{{ total }} 条记录 {{ currentPage }} / {{ totalPages }}
</div>
<div class="flex items-center space-x-2">
<Button size="sm" variant="outline" :disabled="currentPage <= 1" @click="$emit('page-change', currentPage - 1)">
上一页
</Button>
<Button size="sm" variant="outline" :disabled="currentPage >= totalPages"
@click="$emit('page-change', currentPage + 1)">
下一页
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
interface Props {
total: number
currentPage: number
pageSize: number
}
const props = defineProps<Props>()
// const _emit = defineEmits<{
// 'page-change': [page: number]
// }>()
const totalPages = computed(() => {
return Math.ceil(props.total / props.pageSize)
})
</script>
<script lang="ts">
export default {
name: 'Pagination'
}
</script>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+26
View File
@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+37
View File
@@ -0,0 +1,37 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95 active:transition-transform active:duration-75",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 active:bg-primary/80 hover:shadow-sm",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 active:bg-destructive/80 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 hover:shadow-sm",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground active:bg-accent/80 dark:bg-input/30 dark:border-input dark:hover:bg-input/50 hover:shadow-sm",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 active:bg-secondary/70 hover:shadow-sm",
ghost:
"hover:bg-accent hover:text-accent-foreground active:bg-accent/80 dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { ComboboxRootEmits, ComboboxRootProps } from "reka-ui"
import { ComboboxRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<ComboboxRootProps>()
const emits = defineEmits<ComboboxRootEmits>()
const forwarded = useForwardPropsEmits(props, emits) as any
</script>
<template>
<ComboboxRoot
data-slot="combobox"
v-bind="forwarded"
>
<slot />
</ComboboxRoot>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ComboboxAnchorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxAnchor, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxAnchor
data-slot="combobox-anchor"
v-bind="forwarded"
:class="cn('w-[200px]', props.class)"
>
<slot />
</ComboboxAnchor>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ComboboxEmptyProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxEmpty } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxEmptyProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ComboboxEmpty
data-slot="combobox-empty"
v-bind="delegatedProps"
:class="cn('py-6 text-center text-sm', props.class)"
>
<slot />
</ComboboxEmpty>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ComboboxGroupProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxGroup, ComboboxLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxGroupProps & {
class?: HTMLAttributes["class"]
heading?: string
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ComboboxGroup
data-slot="combobox-group"
v-bind="delegatedProps"
:class="cn('overflow-hidden p-1 text-foreground', props.class)"
>
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ComboboxLabel>
<slot />
</ComboboxGroup>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { ComboboxInputEmits, ComboboxInputProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SearchIcon } from "lucide-vue-next"
import { ComboboxInput, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ComboboxInputProps & {
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<ComboboxInputEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) as any
</script>
<template>
<div
data-slot="command-input-wrapper"
class="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon class="size-4 shrink-0 opacity-50" />
<ComboboxInput
data-slot="command-input"
:class="cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
</ComboboxInput>
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxItem, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ComboboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) as any
</script>
<template>
<ComboboxItem
data-slot="combobox-item"
v-bind="forwarded"
:class="cn(`data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`, props.class)"
>
<slot />
</ComboboxItem>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ComboboxItemIndicatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxItemIndicator, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxItemIndicatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxItemIndicator
data-slot="combobox-item-indicator"
v-bind="forwarded"
:class="cn('ml-auto', props.class)"
>
<slot />
</ComboboxItemIndicator>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ComboboxContentEmits, ComboboxContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxContent, ComboboxPortal, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<ComboboxContentProps & { class?: HTMLAttributes["class"] }>(), {
position: "popper",
align: "center",
sideOffset: 4,
})
const emits = defineEmits<ComboboxContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) as any
</script>
<template>
<ComboboxPortal>
<ComboboxContent
data-slot="combobox-list"
v-bind="forwarded"
:class="cn('z-50 w-[200px] rounded-md border bg-popover text-popover-foreground origin-(--reka-combobox-content-transform-origin) overflow-hidden shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
>
<slot />
</ComboboxContent>
</ComboboxPortal>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ComboboxSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ComboboxSeparator
data-slot="combobox-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 h-px', props.class)"
>
<slot />
</ComboboxSeparator>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { ComboboxTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxTrigger
data-slot="combobox-trigger"
v-bind="forwarded"
:class="cn('', props.class)"
tabindex="0"
>
<slot />
</ComboboxTrigger>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ComboboxViewportProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxViewport, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxViewportProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxViewport
data-slot="combobox-viewport"
v-bind="forwarded"
:class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)"
>
<slot />
</ComboboxViewport>
</template>
+12
View File
@@ -0,0 +1,12 @@
export { default as Combobox } from "./Combobox.vue"
export { default as ComboboxAnchor } from "./ComboboxAnchor.vue"
export { default as ComboboxEmpty } from "./ComboboxEmpty.vue"
export { default as ComboboxGroup } from "./ComboboxGroup.vue"
export { default as ComboboxInput } from "./ComboboxInput.vue"
export { default as ComboboxItem } from "./ComboboxItem.vue"
export { default as ComboboxItemIndicator } from "./ComboboxItemIndicator.vue"
export { default as ComboboxList } from "./ComboboxList.vue"
export { default as ComboboxSeparator } from "./ComboboxSeparator.vue"
export { default as ComboboxViewport } from "./ComboboxViewport.vue"
export { ComboboxCancel, ComboboxTrigger } from "reka-ui"
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits) as any
</script>
<template>
<DialogRoot
data-slot="dialog"
v-bind="forwarded"
>
<slot />
</DialogRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) as any
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) as any
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>
+10
View File
@@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { DrawerRootEmits, DrawerRootProps } from "vaul-vue"
import { useForwardPropsEmits } from "reka-ui"
import { DrawerRoot } from "vaul-vue"
const props = withDefaults(defineProps<DrawerRootProps>(), {
shouldScaleBackground: true,
})
const emits = defineEmits<DrawerRootEmits>()
const forwarded = useForwardPropsEmits(props, emits) as any
</script>
<template>
<DrawerRoot
data-slot="drawer"
v-bind="forwarded"
>
<slot />
</DrawerRoot>
</template>
@@ -0,0 +1,15 @@
<script lang="ts" setup>
import type { DrawerCloseProps } from "vaul-vue"
import { DrawerClose } from "vaul-vue"
const props = defineProps<DrawerCloseProps>()
</script>
<template>
<DrawerClose
data-slot="drawer-close"
v-bind="props"
>
<slot />
</DrawerClose>
</template>
@@ -0,0 +1,34 @@
<script lang="ts" setup>
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { useForwardPropsEmits } from "reka-ui"
import { DrawerContent, DrawerPortal } from "vaul-vue"
import { cn } from "@/lib/utils"
import DrawerOverlay from "./DrawerOverlay.vue"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(props, emits) as any
</script>
<template>
<DrawerPortal>
<DrawerOverlay />
<DrawerContent
data-slot="drawer-content"
v-bind="forwarded"
:class="cn(
`group/drawer-content bg-background fixed z-50 flex h-auto flex-col`,
`data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg`,
`data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg`,
`data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:sm:max-w-sm`,
`data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm`,
props.class,
)"
>
<div class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
<slot />
</DrawerContent>
</DrawerPortal>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { DrawerDescriptionProps } from "vaul-vue"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DrawerDescription } from "vaul-vue"
import { cn } from "@/lib/utils"
const props = defineProps<DrawerDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DrawerDescription
data-slot="drawer-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DrawerDescription>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="drawer-footer"
:class="cn('mt-auto flex flex-col gap-2 p-4', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="drawer-header"
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DrawerOverlay } from "vaul-vue"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DrawerOverlay
data-slot="drawer-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
/>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { DrawerTitleProps } from "vaul-vue"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DrawerTitle } from "vaul-vue"
import { cn } from "@/lib/utils"
const props = defineProps<DrawerTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DrawerTitle
data-slot="drawer-title"
v-bind="delegatedProps"
:class="cn('text-foreground font-semibold', props.class)"
>
<slot />
</DrawerTitle>
</template>
@@ -0,0 +1,15 @@
<script lang="ts" setup>
import type { DrawerTriggerProps } from "vaul-vue"
import { DrawerTrigger } from "vaul-vue"
const props = defineProps<DrawerTriggerProps>()
</script>
<template>
<DrawerTrigger
data-slot="drawer-trigger"
v-bind="props"
>
<slot />
</DrawerTrigger>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as Drawer } from "./Drawer.vue"
export { default as DrawerClose } from "./DrawerClose.vue"
export { default as DrawerContent } from "./DrawerContent.vue"
export { default as DrawerDescription } from "./DrawerDescription.vue"
export { default as DrawerFooter } from "./DrawerFooter.vue"
export { default as DrawerHeader } from "./DrawerHeader.vue"
export { default as DrawerOverlay } from "./DrawerOverlay.vue"
export { default as DrawerTitle } from "./DrawerTitle.vue"
export { default as DrawerTrigger } from "./DrawerTrigger.vue"
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from "./Input.vue"
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Label } from "./Label.vue"
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<RadioGroupRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<RadioGroupRoot
data-slot="radio-group"
:class="cn('grid gap-3', props.class)"
v-bind="forwarded"
>
<slot />
</RadioGroupRoot>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { RadioGroupItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CircleIcon } from "lucide-vue-next"
import {
RadioGroupIndicator,
RadioGroupItem,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RadioGroupItem
data-slot="radio-group-item"
v-bind="forwardedProps"
:class="
cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<RadioGroupIndicator
data-slot="radio-group-indicator"
class="relative flex items-center justify-center"
>
<CircleIcon class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupIndicator>
</RadioGroupItem>
</template>
@@ -0,0 +1,2 @@
export { default as RadioGroup } from "./RadioGroup.vue"
export { default as RadioGroupItem } from "./RadioGroupItem.vue"
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits) as any
</script>
<template>
<SelectRoot
data-slot="select"
v-bind="forwarded"
>
<slot />
</SelectRoot>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectGroupProps } from "reka-ui"
import { SelectGroup } from "reka-ui"
const props = defineProps<SelectGroupProps>()
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="props"
>
<slot />
</SelectGroup>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
`focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2`,
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="size-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('px-2 py-1.5 text-sm font-medium', props.class)"
>
<slot />
</SelectLabel>
</template>

Some files were not shown because too many files have changed in this diff Show More