feat: reoede web to tailwindcss
This commit is contained in:
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Vendored
+10
-26
@@ -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>
|
||||
Generated
+2443
-5751
File diff suppressed because it is too large
Load Diff
+28
-27
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Vendored
+11
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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; */
|
||||
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
@import './base.css';
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="请输入自定义的网站logo(svg文本)" />
|
||||
<!-- 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user