Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled

Full-stack web application for Telegram management
- Frontend: Vue 3 + Vben Admin
- Backend: NestJS
- Features: User management, group broadcast, statistics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

29
marketing-agent/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment files
.env
.env.local
.env.*.local

View File

@@ -0,0 +1,47 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application with production environment
ARG VITE_API_URL=/api
ARG VITE_ENVIRONMENT=production
RUN npm run build
# Production stage
FROM nginx:alpine
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Copy built files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Create cache directory for nginx
RUN mkdir -p /var/cache/nginx && \
chown -R nginx:nginx /var/cache/nginx
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1
# Use dumb-init to handle signals
ENTRYPOINT ["dumb-init", "--"]
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,203 @@
# Mobile Responsive Support
This document describes the mobile responsive implementation for the Marketing Agent System frontend.
## Overview
The frontend has been enhanced with comprehensive mobile responsive support, providing an optimized experience across all device sizes.
## Features
### 1. Responsive Layout System
- **Automatic Layout Switching**: The application automatically detects device type and switches between desktop and mobile layouts
- **Breakpoints**:
- Mobile: < 768px
- Tablet: 768px - 1024px
- Desktop: > 1024px
### 2. Mobile-Specific Components
#### Mobile Layout (`LayoutMobile.vue`)
- Hamburger menu for navigation
- Bottom navigation bar for quick access to main features
- Optimized header with essential actions
- Slide-out sidebar for full menu access
#### Mobile Dashboard (`DashboardMobile.vue`)
- Compact stat cards in 2-column grid
- Optimized charts with mobile-friendly options
- Quick action floating button
- Touch-friendly interface elements
#### Mobile Campaign List (`CampaignListMobile.vue`)
- Card-based layout for better readability
- Swipe actions for quick operations
- Load more pagination instead of traditional pagination
- Inline stats and progress indicators
#### Mobile Analytics (`AnalyticsMobile.vue`)
- Scrollable metric cards
- Responsive charts with touch interactions
- Collapsible sections for better organization
- Export options via floating action button
### 3. Responsive Utilities
#### `useResponsive` Composable
```javascript
import { useResponsive } from '@/composables/useResponsive'
const { isMobile, isTablet, isDesktop, screenWidth, screenHeight } = useResponsive()
```
### 4. Mobile-Optimized Styles
- Touch-friendly button sizes (minimum 44px height)
- Larger input fields to prevent zoom on iOS
- Optimized spacing for mobile screens
- Smooth scrolling with momentum
- Bottom safe area padding for iOS devices
### 5. Performance Optimizations
- Lazy loading for mobile components
- Reduced data fetching on mobile
- Optimized images and assets
- Simplified animations for better performance
## Implementation Guide
### Using Responsive Components
1. **In Views**: Components automatically detect mobile and render appropriate version
```vue
<template>
<DashboardMobile v-if="isMobile" />
<div v-else>
<!-- Desktop content -->
</div>
</template>
```
2. **Responsive Classes**: Use Tailwind responsive prefixes
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
<!-- Responsive grid -->
</div>
```
3. **Mobile-First Approach**: Design for mobile first, then enhance for larger screens
### Touch Interactions
- Swipe gestures for navigation and actions
- Pull-to-refresh on scrollable lists
- Long press for context menus
- Pinch-to-zoom disabled on UI elements
### Navigation Patterns
1. **Bottom Navigation**: Primary navigation for most-used features
2. **Hamburger Menu**: Full navigation access via slide-out menu
3. **Floating Action Buttons**: Quick access to primary actions
4. **Breadcrumbs**: Simplified on mobile, showing only current location
## Best Practices
### 1. Content Prioritization
- Show most important information first
- Use progressive disclosure for details
- Minimize cognitive load with clear hierarchy
### 2. Touch Targets
- Minimum 44x44px for all interactive elements
- Adequate spacing between clickable items
- Clear visual feedback for touch interactions
### 3. Forms
- Use appropriate input types (email, tel, number)
- Single column layout for forms
- Clear labels and error messages
- Auto-focus management for better UX
### 4. Performance
- Minimize JavaScript execution on scroll
- Use CSS transforms for animations
- Lazy load images and components
- Reduce API calls with smart caching
### 5. Accessibility
- Proper ARIA labels for screen readers
- Sufficient color contrast
- Focus management for keyboard navigation
- Touch-friendly alternatives for hover states
## Testing
### Device Testing
Test on real devices when possible:
- iOS Safari (iPhone/iPad)
- Android Chrome
- Android Firefox
- Various screen sizes and orientations
### Browser DevTools
- Use responsive mode in Chrome/Firefox DevTools
- Test touch events and gestures
- Verify performance on throttled connections
### Automated Testing
- Viewport-specific tests
- Touch event simulation
- Performance budgets for mobile
## Known Limitations
1. **Complex Tables**: Simplified on mobile with essential columns only
2. **Advanced Filters**: Moved to dedicated modal on mobile
3. **Drag & Drop**: Touch-friendly alternatives provided
4. **Hover States**: Replaced with tap interactions
## Future Enhancements
1. **Progressive Web App (PWA)**
- Offline support
- Install to home screen
- Push notifications
2. **Advanced Gestures**
- Swipe between views
- Pull-to-refresh on all lists
- Gesture-based shortcuts
3. **Adaptive Loading**
- Lower quality images on slow connections
- Reduced data mode
- Progressive enhancement based on device capabilities
## Troubleshooting
### Common Issues
1. **iOS Zoom on Input Focus**
- Solution: Set font-size to 16px on inputs
2. **Bottom Bar Overlap on iOS**
- Solution: Use `env(safe-area-inset-bottom)`
3. **Horizontal Scroll**
- Solution: Check for elements exceeding viewport width
4. **Performance Issues**
- Solution: Profile with Chrome DevTools, reduce re-renders
### Debug Mode
Enable mobile debug mode in development:
```javascript
// In .env.development
VITE_MOBILE_DEBUG=true
```
This will show device info and performance metrics on mobile devices.

View File

@@ -0,0 +1,241 @@
# Frontend Performance Optimization Guide
This guide documents all performance optimizations implemented in the Telegram Marketing Agent frontend.
## Overview
The frontend has been optimized for performance across multiple dimensions:
- Initial load time
- Runtime performance
- Memory usage
- Network efficiency
- User experience
## Build Optimizations
### 1. Code Splitting
- Automatic vendor chunks for better caching
- Manual chunks for large libraries (Element Plus, Chart.js)
- Route-based code splitting with lazy loading
### 2. Compression
- Gzip compression for all assets > 10KB
- Brotli compression for better compression ratios
- Image optimization with quality settings
### 3. Asset Optimization
- Proper asset naming for cache busting
- Inline small assets (< 4KB)
- Organized asset directories
## Runtime Optimizations
### 1. Lazy Loading
- **Images**: Intersection Observer-based lazy loading
- **Components**: Dynamic imports with loading/error states
- **Routes**: Lazy-loaded route components
```vue
<!-- Image lazy loading -->
<img v-lazy="imageSrc" :alt="imageAlt">
<!-- Background lazy loading -->
<div v-lazy-bg="backgroundUrl"></div>
<!-- Progressive image loading -->
<img v-progressive="{ lowQuality: thumbUrl, highQuality: fullUrl }">
```
### 2. Virtual Scrolling
For large lists, use the VirtualList component:
```vue
<VirtualList
:items="items"
:item-height="50"
v-slot="{ item }"
>
<div>{{ item.name }}</div>
</VirtualList>
```
### 3. Web Workers
Heavy computations are offloaded to web workers:
```javascript
import { useComputationWorker } from '@/composables/useWebWorker'
const { sort, filter, aggregate, loading, result } = useComputationWorker()
// Sort large dataset
await sort(largeArray)
// Filter data
await filter(items, 'status', 'active')
```
### 4. Debouncing & Throttling
```javascript
import { debounce, throttle } from '@/utils/performance'
// Debounce search input
const search = debounce((query) => {
// Search logic
}, 300)
// Throttle scroll handler
const handleScroll = throttle(() => {
// Scroll logic
}, 100)
```
## State Management
### 1. Persisted State
Store state is automatically persisted with debouncing:
```javascript
// In store configuration
export const useUserStore = defineStore('user', {
persist: {
paths: ['profile', 'preferences'],
debounceTime: 1000
}
})
```
### 2. Memory Management
- WeakMap for object references
- Automatic cleanup on component unmount
- Memory leak detection in development
## Network Optimizations
### 1. Service Worker
- Offline support with cache strategies
- Background sync for failed requests
- Push notifications support
### 2. API Caching
- Cache-first for static data
- Network-first with cache fallback for dynamic data
- Stale-while-revalidate for frequently updated data
### 3. Request Optimization
- Request batching for multiple API calls
- Request deduplication
- Automatic retry with exponential backoff
## Monitoring & Analytics
### 1. Performance Metrics
The app automatically tracks:
- First Contentful Paint (FCP)
- Largest Contentful Paint (LCP)
- First Input Delay (FID)
- Cumulative Layout Shift (CLS)
- Time to Interactive (TTI)
### 2. Error Tracking
- Global error handler
- Source maps for production debugging
- User context in error reports
### 3. User Analytics
- Page view tracking
- User interaction events
- Performance impact analysis
## Best Practices
### 1. Component Development
- Use `shallowRef` for large objects
- Implement `v-memo` for expensive renders
- Use `computed` instead of methods for derived state
### 2. Event Handling
- Use passive event listeners
- Delegate events when possible
- Clean up listeners on unmount
### 3. Asset Loading
- Preload critical resources
- Use resource hints (prefetch, preconnect)
- Implement responsive images
### 4. Bundle Size
- Tree-shake unused code
- Use dynamic imports for optional features
- Monitor bundle size with visualizer
## Development Tools
### 1. Performance Profiling
```bash
# Generate bundle analysis
npm run build -- --report
# Profile runtime performance
window.__PERFORMANCE__.getMetrics()
```
### 2. Lighthouse CI
Run Lighthouse in CI to track performance over time:
```bash
npm run lighthouse
```
### 3. Memory Profiling
Use Chrome DevTools Memory Profiler to identify leaks.
## Configuration Files
### vite.config.optimized.js
Contains all build optimizations including:
- Code splitting configuration
- Compression plugins
- Asset optimization
- Performance hints
### Performance Budget
Target metrics:
- FCP: < 1.8s
- LCP: < 2.5s
- TTI: < 3.8s
- Bundle size: < 500KB (initial)
## Checklist
Before deploying, ensure:
- [ ] Images are optimized and lazy-loaded
- [ ] Large lists use virtual scrolling
- [ ] Heavy computations use web workers
- [ ] API calls are cached appropriately
- [ ] Service worker is registered (production)
- [ ] Performance metrics are within budget
- [ ] No memory leaks detected
- [ ] Bundle size is optimized
## Troubleshooting
### High Memory Usage
1. Check for detached DOM nodes
2. Review event listener cleanup
3. Verify store subscription cleanup
### Slow Initial Load
1. Review bundle splitting
2. Check for blocking resources
3. Verify compression is working
### Poor Runtime Performance
1. Profile with Chrome DevTools
2. Check for unnecessary re-renders
3. Review computed property usage
## Future Optimizations
1. **HTTP/3 Support**: When available
2. **Module Federation**: For micro-frontends
3. **Edge Computing**: For global performance
4. **AI-Powered Prefetching**: Predictive resource loading

View File

@@ -0,0 +1,217 @@
# Telegram Marketing Agent - Frontend
Modern Vue 3 management interface for the Telegram Marketing Agent System.
## Features
- 🎯 **Campaign Management**: Create, manage, and monitor marketing campaigns
- 📊 **Real-time Analytics**: Track performance metrics and engagement rates
- 🧠 **A/B Testing**: Built-in experimentation framework
- 🤖 **AI Integration**: Claude-powered strategy generation and optimization
- 🔐 **Compliance Tools**: GDPR/CCPA compliance management
- 🌍 **Multi-language Support**: English and Chinese interfaces
- 🎨 **Modern UI**: Built with Element Plus and Tailwind CSS
## Tech Stack
- **Framework**: Vue 3 with Composition API
- **State Management**: Pinia
- **Routing**: Vue Router 4
- **UI Library**: Element Plus
- **Styling**: Tailwind CSS
- **Build Tool**: Vite
- **Charts**: Chart.js with vue-chartjs
- **HTTP Client**: Axios
- **Internationalization**: Vue I18n
## Getting Started
### Prerequisites
- Node.js 18+ and npm 8+
- Backend services running (see main README)
### Installation
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
The application will be available at http://localhost:3008
### Environment Configuration
The frontend proxies API requests to the backend. Configure the proxy in `vite.config.js`:
```javascript
server: {
proxy: {
'/api': {
target: 'http://localhost:3000', // API Gateway URL
changeOrigin: true
}
}
}
```
## Project Structure
```
src/
├── api/ # API client modules
│ ├── index.js # Axios configuration
│ └── modules/ # API endpoints by domain
├── assets/ # Static assets
├── components/ # Reusable components
├── locales/ # i18n translations
├── router/ # Vue Router configuration
├── stores/ # Pinia stores
├── utils/ # Utility functions
├── views/ # Page components
├── App.vue # Root component
├── main.js # Application entry
└── style.css # Global styles
```
## Key Features
### Campaign Management
- Create campaigns with AI-powered strategy generation
- Multi-step message sequences with delays
- Target audience selection by groups and tags
- Real-time campaign status monitoring
- Campaign lifecycle management (start, pause, resume, cancel)
### Analytics Dashboard
- Key performance metrics with trend analysis
- Time-series charts for message and engagement data
- Campaign performance comparison
- Real-time activity feed
- Export reports in multiple formats
### A/B Testing
- Create experiments with multiple variants
- Statistical significance testing
- Real-time result monitoring
- Winner selection and rollout
### Compliance Management
- User consent tracking
- Data export/deletion requests
- Audit log viewing
- GDPR/CCPA compliance status
## Development
### Available Scripts
```bash
# Development server
npm run dev
# Production build
npm run build
# Preview production build
npm run preview
# Lint and fix
npm run lint
# Format code
npm run format
```
### Code Style
- Use Composition API with `<script setup>`
- Prefer TypeScript-like prop definitions
- Use Tailwind for utility classes
- Use Element Plus components for consistency
### State Management
The application uses Pinia for state management:
```javascript
// stores/auth.js
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref('')
// Store logic...
})
```
### API Integration
All API calls go through the centralized API client:
```javascript
import api from '@/api'
// Example usage
const campaigns = await api.campaigns.getList({
page: 1,
pageSize: 20
})
```
## Building for Production
```bash
# Build the application
npm run build
# Files will be in dist/
# Serve with any static file server
```
### Nginx Configuration
```nginx
server {
listen 80;
server_name your-domain.com;
root /var/www/marketing-agent;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://api-gateway:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## Security Considerations
- All API requests require authentication
- JWT tokens stored in localStorage
- Automatic token refresh on 401 responses
- CORS configured for production domains
- Input validation on all forms
- XSS protection via Vue's template compilation
## Contributing
1. Follow the existing code style
2. Write meaningful commit messages
3. Add appropriate error handling
4. Update translations for new features
5. Test across different screen sizes
## License
See the main project LICENSE file.

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Marketing Agent</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# API proxy
location /api/ {
proxy_pass http://api-gateway:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket support
location /socket.io/ {
proxy_pass http://api-gateway:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Vue Router support - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

View File

@@ -0,0 +1,50 @@
{
"name": "marketing-agent-frontend",
"version": "1.0.0",
"description": "Frontend management interface for Telegram Marketing Agent System",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx --fix",
"format": "prettier --write src/",
"lighthouse": "lighthouse http://localhost:3008 --output html --output-path ./lighthouse-report.html",
"lighthouse:ci": "lighthouse http://localhost:3008 --output json --output-path ./lighthouse-report.json --chrome-flags='--headless'",
"test:performance": "npm run build && npm run preview & sleep 5 && npm run lighthouse:ci"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.5",
"chart.js": "^4.4.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.10",
"element-plus": "^2.4.4",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"socket.io-client": "^4.6.0",
"vue": "^3.4.15",
"vue-chartjs": "^5.3.0",
"vue-i18n": "^9.9.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.20.1",
"lighthouse": "^11.4.0",
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.70.0",
"tailwindcss": "^3.4.1",
"unplugin-auto-import": "^0.17.3",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.11",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-imagemin": "^0.6.1",
"workbox-webpack-plugin": "^7.0.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clear and Test</title>
</head>
<body>
<h1>Clear Storage and Test Login</h1>
<button onclick="clearStorage()">Clear All Storage</button>
<button onclick="testAuth()">Test Current Auth</button>
<button onclick="goToLogin()">Go to Login Page</button>
<button onclick="goToHome()">Go to Home (Protected)</button>
<div id="result" style="margin-top: 20px; padding: 10px; border: 1px solid #ddd;"></div>
<script>
function clearStorage() {
localStorage.clear();
sessionStorage.clear();
document.getElementById('result').innerHTML = 'All storage cleared!';
}
function testAuth() {
const token = localStorage.getItem('token');
const result = document.getElementById('result');
if (token) {
result.innerHTML = `
<p style="color: green;">Authenticated!</p>
<p>Token: ${token.substring(0, 50)}...</p>
`;
} else {
result.innerHTML = '<p style="color: red;">Not authenticated</p>';
}
}
function goToLogin() {
window.location.href = '/login';
}
function goToHome() {
window.location.href = '/';
}
// Show current status on load
window.onload = testAuth;
</script>
</body>
</html>

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Test</title>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
input { margin: 5px 0; padding: 5px; width: 200px; }
button { margin: 10px 0; padding: 10px 20px; background: #4CAF50; color: white; border: none; cursor: pointer; }
button:hover { background: #45a049; }
.result { margin-top: 20px; padding: 10px; border: 1px solid #ddd; }
.error { color: red; }
.success { color: green; }
</style>
</head>
<body>
<h1>Login Test</h1>
<div>
<input type="text" id="username" value="admin" placeholder="Username"><br>
<input type="password" id="password" value="admin123456" placeholder="Password"><br>
<button onclick="login()">Login</button>
</div>
<div id="result" class="result"></div>
<script>
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const resultDiv = document.getElementById('result');
try {
// Login
const response = await axios.post('/api/v1/auth/login', {
username: username,
password: password
});
console.log('Login response:', response.data);
if (response.data.success) {
const token = response.data.data.accessToken;
const user = response.data.data.user;
resultDiv.innerHTML = `
<div class="success">
<h3>Login Successful!</h3>
<p>User: ${user.username} (${user.role})</p>
<p>Token stored in localStorage</p>
<button onclick="testDashboard()">Test Dashboard Access</button>
<button onclick="goToApp()">Go to Application</button>
</div>
`;
// Store token
localStorage.setItem('token', token);
localStorage.setItem('refreshToken', response.data.data.refreshToken);
} else {
resultDiv.innerHTML = `<div class="error">Login failed: ${response.data.error}</div>`;
}
} catch (error) {
console.error('Login error:', error);
resultDiv.innerHTML = `<div class="error">Error: ${error.message}</div>`;
}
}
async function testDashboard() {
const token = localStorage.getItem('token');
if (!token) {
alert('No token found!');
return;
}
try {
const response = await axios.get('/api/v1/analytics/dashboard', {
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log('Dashboard data:', response.data);
alert('Dashboard access successful! Check console for data.');
} catch (error) {
console.error('Dashboard error:', error);
alert('Dashboard error: ' + error.message);
}
}
function goToApp() {
window.location.href = '/';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,246 @@
// Service Worker for offline support and performance optimization
const CACHE_NAME = 'marketing-agent-v1'
const STATIC_CACHE_NAME = 'marketing-agent-static-v1'
const DYNAMIC_CACHE_NAME = 'marketing-agent-dynamic-v1'
const API_CACHE_NAME = 'marketing-agent-api-v1'
// Files to cache immediately
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/images/logo.png',
'/images/placeholder.png'
]
// API endpoints to cache
const CACHEABLE_API_PATTERNS = [
/\/api\/v1\/users\/profile$/,
/\/api\/v1\/campaigns\/templates$/,
/\/api\/v1\/settings$/
]
// Install event - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS)
})
)
self.skipWaiting()
})
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => {
return cacheName.startsWith('marketing-agent-') &&
cacheName !== CACHE_NAME &&
cacheName !== STATIC_CACHE_NAME &&
cacheName !== DYNAMIC_CACHE_NAME &&
cacheName !== API_CACHE_NAME
})
.map((cacheName) => caches.delete(cacheName))
)
})
)
self.clients.claim()
})
// Fetch event - implement caching strategies
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// Skip non-GET requests
if (request.method !== 'GET') {
return
}
// Handle API requests
if (url.pathname.startsWith('/api/')) {
event.respondWith(handleApiRequest(request))
return
}
// Handle static assets
if (isStaticAsset(url.pathname)) {
event.respondWith(handleStaticAsset(request))
return
}
// Handle dynamic content
event.respondWith(handleDynamicContent(request))
})
// Cache-first strategy for static assets
async function handleStaticAsset(request) {
const cache = await caches.open(STATIC_CACHE_NAME)
const cached = await cache.match(request)
if (cached) {
return cached
}
try {
const response = await fetch(request)
if (response.ok) {
cache.put(request, response.clone())
}
return response
} catch (error) {
return new Response('Offline - Asset not available', {
status: 503,
statusText: 'Service Unavailable'
})
}
}
// Network-first strategy for API requests with fallback
async function handleApiRequest(request) {
const cache = await caches.open(API_CACHE_NAME)
try {
const response = await fetch(request)
// Cache successful responses for cacheable endpoints
if (response.ok && isCacheableApi(request.url)) {
cache.put(request, response.clone())
}
return response
} catch (error) {
// Try cache on network failure
const cached = await cache.match(request)
if (cached) {
// Add header to indicate cached response
const headers = new Headers(cached.headers)
headers.set('X-From-Cache', 'true')
return new Response(cached.body, {
status: cached.status,
statusText: cached.statusText,
headers
})
}
// Return offline response
return new Response(
JSON.stringify({
error: 'Network unavailable',
offline: true
}),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' }
}
)
}
}
// Stale-while-revalidate strategy for dynamic content
async function handleDynamicContent(request) {
const cache = await caches.open(DYNAMIC_CACHE_NAME)
const cached = await cache.match(request)
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone())
}
return response
})
return cached || fetchPromise
}
// Helper functions
function isStaticAsset(pathname) {
return /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$/.test(pathname)
}
function isCacheableApi(url) {
return CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url))
}
// Background sync for offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-campaigns') {
event.waitUntil(syncCampaigns())
}
})
async function syncCampaigns() {
const cache = await caches.open('offline-campaigns')
const requests = await cache.keys()
for (const request of requests) {
try {
const response = await fetch(request)
if (response.ok) {
await cache.delete(request)
}
} catch (error) {
console.error('Failed to sync:', error)
}
}
}
// Push notifications
self.addEventListener('push', (event) => {
const options = {
body: event.data ? event.data.text() : 'New notification',
icon: '/images/logo.png',
badge: '/images/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [
{
action: 'view',
title: 'View',
icon: '/images/checkmark.png'
},
{
action: 'close',
title: 'Close',
icon: '/images/xmark.png'
}
]
}
event.waitUntil(
self.registration.showNotification('Marketing Agent', options)
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
if (event.action === 'view') {
event.waitUntil(
clients.openWindow('/')
)
}
})
// Message handling for cache control
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
} else if (event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName))
)
})
)
}
})

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Test</title>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<h1>Login Test</h1>
<button onclick="testLogin()">Test Login</button>
<div id="result"></div>
<script>
async function testLogin() {
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = 'Testing login...';
try {
const response = await axios.post('/api/v1/auth/login', {
username: 'admin',
password: 'admin123456'
});
console.log('Response:', response.data);
if (response.data.success) {
resultDiv.innerHTML = `
<h3>Login Success!</h3>
<p>Token: ${response.data.data.accessToken.substring(0, 50)}...</p>
<p>User: ${response.data.data.user.username} (${response.data.data.user.role})</p>
<button onclick="testDashboard('${response.data.data.accessToken}')">Test Dashboard API</button>
`;
} else {
resultDiv.innerHTML = `<h3>Login Failed: ${response.data.error}</h3>`;
}
} catch (error) {
resultDiv.innerHTML = `<h3>Error: ${error.message}</h3>`;
console.error('Login error:', error);
}
}
async function testDashboard(token) {
try {
const response = await axios.get('/api/v1/analytics/dashboard', {
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log('Dashboard response:', response.data);
alert('Dashboard API works! Check console for data.');
} catch (error) {
console.error('Dashboard error:', error);
alert('Dashboard API error: ' + error.message);
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Fix</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
.step { margin: 20px 0; padding: 10px; border: 1px solid #ddd; }
.success { color: green; }
.error { color: red; }
button { padding: 10px 20px; margin: 5px; cursor: pointer; }
</style>
</head>
<body>
<h1>Verify Login Fix</h1>
<div class="step">
<h3>Step 1: Clear Everything</h3>
<button onclick="clearAll()">Clear Cache & Reload</button>
</div>
<div class="step">
<h3>Step 2: Test Login</h3>
<button onclick="testLogin()">Login as Admin</button>
<div id="loginResult"></div>
</div>
<div class="step">
<h3>Step 3: Navigate to Dashboard</h3>
<button onclick="goToDashboard()">Go to Dashboard</button>
</div>
<script>
function clearAll() {
localStorage.clear();
sessionStorage.clear();
// Force reload to clear any cached modules
location.href = location.href + '?t=' + Date.now();
}
async function testLogin() {
const resultDiv = document.getElementById('loginResult');
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'admin',
password: 'admin123456'
})
});
const data = await response.json();
if (data.success) {
localStorage.setItem('token', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
resultDiv.innerHTML = '<p class="success">✓ Login successful! Token saved.</p>';
} else {
resultDiv.innerHTML = '<p class="error">✗ Login failed: ' + data.error + '</p>';
}
} catch (error) {
resultDiv.innerHTML = '<p class="error">✗ Error: ' + error.message + '</p>';
}
}
function goToDashboard() {
window.location.href = '/';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
<template>
<el-config-provider :locale="locale">
<router-view />
</el-config-provider>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
const { locale: i18nLocale } = useI18n()
const locale = computed(() => {
return i18nLocale.value === 'zh' ? zhCn : en
})
</script>

View File

@@ -0,0 +1,289 @@
import request from '@/utils/request'
// Subscriptions
export function getSubscriptions() {
return request({
url: '/api/v1/billing/subscriptions',
method: 'get'
})
}
export function getSubscription(id) {
return request({
url: `/api/v1/billing/subscriptions/${id}`,
method: 'get'
})
}
export function createSubscription(data) {
return request({
url: '/api/v1/billing/subscriptions',
method: 'post',
data
})
}
export function updateSubscription(id, data) {
return request({
url: `/api/v1/billing/subscriptions/${id}`,
method: 'patch',
data
})
}
export function cancelSubscription(id, data) {
return request({
url: `/api/v1/billing/subscriptions/${id}/cancel`,
method: 'post',
data
})
}
export function reactivateSubscription(id) {
return request({
url: `/api/v1/billing/subscriptions/${id}/reactivate`,
method: 'post'
})
}
export function recordUsage(id, data) {
return request({
url: `/api/v1/billing/subscriptions/${id}/usage`,
method: 'post',
data
})
}
export function getSubscriptionUsage(id, params) {
return request({
url: `/api/v1/billing/subscriptions/${id}/usage`,
method: 'get',
params
})
}
export function applyDiscount(id, data) {
return request({
url: `/api/v1/billing/subscriptions/${id}/discount`,
method: 'post',
data
})
}
// Invoices
export function getInvoices(params) {
return request({
url: '/api/v1/billing/invoices',
method: 'get',
params
})
}
export function getInvoice(id) {
return request({
url: `/api/v1/billing/invoices/${id}`,
method: 'get'
})
}
export function createInvoice(data) {
return request({
url: '/api/v1/billing/invoices',
method: 'post',
data
})
}
export function updateInvoice(id, data) {
return request({
url: `/api/v1/billing/invoices/${id}`,
method: 'patch',
data
})
}
export function finalizeInvoice(id) {
return request({
url: `/api/v1/billing/invoices/${id}/finalize`,
method: 'post'
})
}
export function payInvoice(id, data) {
return request({
url: `/api/v1/billing/invoices/${id}/pay`,
method: 'post',
data
})
}
export function voidInvoice(id, data) {
return request({
url: `/api/v1/billing/invoices/${id}/void`,
method: 'post',
data
})
}
export function downloadInvoice(id) {
return request({
url: `/api/v1/billing/invoices/${id}/pdf`,
method: 'get',
responseType: 'blob'
})
}
export function sendInvoiceReminder(id) {
return request({
url: `/api/v1/billing/invoices/${id}/remind`,
method: 'post'
})
}
export function getUnpaidInvoices() {
return request({
url: '/api/v1/billing/invoices/unpaid',
method: 'get'
})
}
export function getOverdueInvoices() {
return request({
url: '/api/v1/billing/invoices/overdue',
method: 'get'
})
}
// Payment Methods
export function getPaymentMethods() {
return request({
url: '/api/v1/billing/payment-methods',
method: 'get'
})
}
export function getPaymentMethod(id) {
return request({
url: `/api/v1/billing/payment-methods/${id}`,
method: 'get'
})
}
export function addPaymentMethod(data) {
return request({
url: '/api/v1/billing/payment-methods',
method: 'post',
data
})
}
export function updatePaymentMethod(id, data) {
return request({
url: `/api/v1/billing/payment-methods/${id}`,
method: 'patch',
data
})
}
export function setDefaultPaymentMethod(id) {
return request({
url: `/api/v1/billing/payment-methods/${id}/default`,
method: 'post'
})
}
export function removePaymentMethod(id) {
return request({
url: `/api/v1/billing/payment-methods/${id}`,
method: 'delete'
})
}
export function verifyPaymentMethod(id, data) {
return request({
url: `/api/v1/billing/payment-methods/${id}/verify`,
method: 'post',
data
})
}
// Transactions
export function getTransactions(params) {
return request({
url: '/api/v1/billing/transactions',
method: 'get',
params
})
}
export function getTransaction(id) {
return request({
url: `/api/v1/billing/transactions/${id}`,
method: 'get'
})
}
export function createRefund(id, data) {
return request({
url: `/api/v1/billing/transactions/${id}/refund`,
method: 'post',
data
})
}
export function getTransactionSummary(period, params) {
return request({
url: `/api/v1/billing/transactions/summary/${period}`,
method: 'get',
params
})
}
export function exportTransactions(format, params) {
return request({
url: `/api/v1/billing/transactions/export/${format}`,
method: 'get',
params,
responseType: 'blob'
})
}
export function createAdjustment(data) {
return request({
url: '/api/v1/billing/transactions/adjustment',
method: 'post',
data
})
}
// Plans
export function getPlans() {
return request({
url: '/api/v1/billing/plans',
method: 'get'
})
}
export function getPlan(id) {
return request({
url: `/api/v1/billing/plans/${id}`,
method: 'get'
})
}
// Coupons
export function validateCoupon(code) {
return request({
url: '/api/v1/billing/coupons/validate',
method: 'post',
data: { code }
})
}
// Stripe Customer Portal
export function createCustomerPortalSession() {
return request({
url: '/api/v1/billing/customer-portal',
method: 'post'
})
}

View File

@@ -0,0 +1,102 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
// Create axios instance
const request = axios.create({
baseURL: '/api/v1',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// Request interceptor
request.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
console.error('Request error:', error)
return Promise.reject(error)
}
)
// Response interceptor
request.interceptors.response.use(
response => {
console.log('API Response:', response.config.url, response.data)
return response
},
error => {
if (error.response) {
switch (error.response.status) {
case 401:
localStorage.removeItem('token')
router.push({ name: 'Login' })
ElMessage.error('Authentication expired, please login again')
break
case 403:
ElMessage.error('Access denied')
break
case 404:
ElMessage.error('Resource not found')
break
case 429:
ElMessage.error('Too many requests, please try again later')
break
case 500:
ElMessage.error('Server error, please try again later')
break
default:
ElMessage.error(error.response.data?.error || 'Operation failed')
}
} else if (error.request) {
ElMessage.error('Network error, please check your connection')
} else {
ElMessage.error('Request failed')
}
return Promise.reject(error)
}
)
// API modules
import auth from './modules/auth'
import campaigns from './modules/campaigns'
import analytics from './modules/analytics'
import abTesting from './modules/abTesting'
import accounts from './modules/accounts'
import compliance from './modules/compliance'
import ai from './modules/ai'
import settings from './modules/settings'
import scheduledCampaigns from './modules/scheduledCampaigns'
import segments from './modules/segments'
import templates from './modules/templates'
const api = {
auth,
campaigns,
analytics,
abTesting,
accounts,
compliance,
ai,
settings,
scheduledCampaigns,
segments,
templates,
setAuthToken(token) {
if (token) {
request.defaults.headers.common['Authorization'] = `Bearer ${token}`
} else {
delete request.defaults.headers.common['Authorization']
}
}
}
export { request }
export default api

View File

@@ -0,0 +1,71 @@
import { request } from '../index'
export default {
// Experiments
getExperiments(params) {
return request.get('/ab-testing/experiments', { params })
},
getExperiment(id) {
return request.get(`/ab-testing/experiments/${id}`)
},
createExperiment(data) {
return request.post('/ab-testing/experiments', data)
},
updateExperiment(id, data) {
return request.put(`/ab-testing/experiments/${id}`, data)
},
deleteExperiment(id) {
return request.delete(`/ab-testing/experiments/${id}`)
},
// Experiment actions
startExperiment(id) {
return request.post(`/ab-testing/experiments/${id}/start`)
},
pauseExperiment(id) {
return request.post(`/ab-testing/experiments/${id}/pause`)
},
stopExperiment(id) {
return request.post(`/ab-testing/experiments/${id}/stop`)
},
// Variants
getVariants(experimentId) {
return request.get(`/ab-testing/experiments/${experimentId}/variants`)
},
createVariant(experimentId, data) {
return request.post(`/ab-testing/experiments/${experimentId}/variants`, data)
},
updateVariant(experimentId, variantId, data) {
return request.put(`/ab-testing/experiments/${experimentId}/variants/${variantId}`, data)
},
deleteVariant(experimentId, variantId) {
return request.delete(`/ab-testing/experiments/${experimentId}/variants/${variantId}`)
},
// Results
getResults(experimentId) {
return request.get(`/ab-testing/experiments/${experimentId}/results`)
},
// Significance test
runSignificanceTest(experimentId) {
return request.post(`/ab-testing/experiments/${experimentId}/significance-test`)
},
// Winner selection
selectWinner(experimentId, variantId) {
return request.post(`/ab-testing/experiments/${experimentId}/select-winner`, {
variantId
})
}
}

View File

@@ -0,0 +1,104 @@
import { request } from '../index'
export default {
// Get accounts list
getList(params) {
return request.get('/accounts', { params })
},
// Telegram accounts
getAccounts(params) {
return request.get('/gramjs-adapter/accounts', { params })
},
// Connect new Telegram account
connectAccount(data) {
return request.post('/gramjs-adapter/accounts/connect', data)
},
// Verify account with code
verifyAccount(accountId, data) {
return request.post(`/gramjs-adapter/accounts/${accountId}/verify`, data)
},
// Get account connection status
getAccountStatus(accountId) {
return request.get(`/gramjs-adapter/accounts/${accountId}/status`)
},
// Disconnect account
disconnectAccount(accountId) {
return request.delete(`/gramjs-adapter/accounts/${accountId}`)
},
// Reconnect account
reconnectAccount(accountId) {
return request.post(`/gramjs-adapter/accounts/${accountId}/reconnect`)
},
getAccount(id) {
return request.get(`/accounts/${id}`)
},
addAccount(data) {
return request.post('/accounts', data)
},
updateAccount(id, data) {
return request.put(`/accounts/${id}`, data)
},
deleteAccount(id) {
return request.delete(`/accounts/${id}`)
},
// Update account status
updateStatus(id, status) {
return request.put(`/accounts/${id}/status`, { status })
},
// Account actions
activateAccount(id) {
return request.post(`/accounts/${id}/activate`)
},
deactivateAccount(id) {
return request.post(`/accounts/${id}/deactivate`)
},
refreshSession(id) {
return request.post(`/accounts/${id}/refresh-session`)
},
// Groups
getGroups(params) {
return request.get('/groups', { params })
},
getGroup(id) {
return request.get(`/groups/${id}`)
},
syncGroups(accountId) {
return request.post(`/accounts/${accountId}/sync-groups`)
},
// Group members
getGroupMembers(groupId, params) {
return request.get(`/groups/${groupId}/members`, { params })
},
// Account statistics
getAccountStats(id) {
return request.get(`/accounts/${id}/stats`)
},
// Batch operations
batchActivate(accountIds) {
return request.post('/accounts/batch/activate', { accountIds })
},
batchDeactivate(accountIds) {
return request.post('/accounts/batch/deactivate', { accountIds })
}
}

View File

@@ -0,0 +1,50 @@
import { request } from '../index'
export default {
// Strategy generation
generateStrategy(data) {
return request.post('/claude/strategy/generate', data)
},
// Campaign analysis
analyzeCampaign(data) {
return request.post('/claude/analysis/campaign', data)
},
// Content generation
generateContent(data) {
return request.post('/claude/content/generate', data)
},
optimizeContent(data) {
return request.post('/claude/content/optimize', data)
},
// Audience analysis
analyzeAudience(data) {
return request.post('/claude/analysis/audience', data)
},
// Predictions
predictPerformance(data) {
return request.post('/claude/predict/performance', data)
},
predictEngagement(data) {
return request.post('/claude/predict/engagement', data)
},
// Recommendations
getRecommendations(type, params) {
return request.get(`/claude/recommendations/${type}`, { params })
},
// Chat interface
sendMessage(data) {
return request.post('/claude/chat', data)
},
getChatHistory(sessionId) {
return request.get(`/claude/chat/history/${sessionId}`)
}
}

View File

@@ -0,0 +1,63 @@
import { request } from '../index'
export default {
// Dashboard metrics
getDashboardMetrics(params) {
return request.get('/analytics/dashboard', { params })
},
// Campaign analytics
getCampaignMetrics(campaignId, params) {
return request.get(`/analytics/campaigns/${campaignId}/metrics`, { params })
},
// Message analytics
getMessageMetrics(params) {
return request.get('/analytics/messages', { params })
},
// Engagement analytics
getEngagementMetrics(params) {
return request.get('/analytics/engagement', { params })
},
// Conversion analytics
getConversionMetrics(params) {
return request.get('/analytics/conversions', { params })
},
// Real-time analytics
getRealTimeMetrics() {
return request.get('/analytics/realtime')
},
// Reports
generateReport(data) {
return request.post('/analytics/reports', data)
},
getReports(params) {
return request.get('/analytics/reports', { params })
},
downloadReport(id) {
return request.get(`/analytics/reports/${id}/download`, {
responseType: 'blob'
})
},
// Custom metrics
trackEvent(data) {
return request.post('/analytics/events', data)
},
// Funnel analytics
getFunnelMetrics(params) {
return request.get('/analytics/funnel', { params })
},
// Cohort analytics
getCohortAnalysis(params) {
return request.get('/analytics/cohorts', { params })
}
}

View File

@@ -0,0 +1,40 @@
import { request } from '../index'
export default {
login(data) {
return request.post('/auth/login', data)
},
register(data) {
return request.post('/auth/register', data)
},
logout() {
return request.post('/auth/logout')
},
getProfile() {
return request.get('/auth/me')
},
updateProfile(data) {
return request.put('/auth/profile', data)
},
changePassword(data) {
return request.post('/auth/change-password', data)
},
// API Key management
getApiKeys() {
return request.get('/auth/api-keys')
},
createApiKey(data) {
return request.post('/auth/api-keys', data)
},
deleteApiKey(id) {
return request.delete(`/auth/api-keys/${id}`)
}
}

View File

@@ -0,0 +1,85 @@
import { request } from '../index'
export default {
// Campaign CRUD
getList(params) {
return request.get('/orchestrator/campaigns', { params })
},
getDetail(id) {
return request.get(`/orchestrator/campaigns/${id}`)
},
create(data) {
return request.post('/orchestrator/campaigns', data)
},
update(id, data) {
return request.put(`/orchestrator/campaigns/${id}`, data)
},
delete(id) {
return request.delete(`/orchestrator/campaigns/${id}`)
},
// Campaign actions
execute(id) {
return request.post(`/orchestrator/campaigns/${id}/execute`)
},
pause(id) {
return request.post(`/orchestrator/campaigns/${id}/pause`)
},
resume(id) {
return request.post(`/orchestrator/campaigns/${id}/resume`)
},
cancel(id) {
return request.post(`/orchestrator/campaigns/${id}/cancel`)
},
clone(id) {
return request.post(`/orchestrator/campaigns/${id}/clone`)
},
// Campaign progress
getProgress(id) {
return request.get(`/orchestrator/campaigns/${id}/progress`)
},
// Campaign statistics
getStatistics(id) {
return request.get(`/orchestrator/campaigns/${id}/statistics`)
},
// Campaign messages
getMessages(id, params) {
return request.get(`/orchestrator/campaigns/${id}/messages`, { params })
},
// Message templates
getTemplates() {
return request.get('/orchestrator/messages/templates')
},
getTemplate(id) {
return request.get(`/orchestrator/messages/templates/${id}`)
},
createTemplate(data) {
return request.post('/orchestrator/messages/templates', data)
},
updateTemplate(id, data) {
return request.put(`/orchestrator/messages/templates/${id}`, data)
},
deleteTemplate(id) {
return request.delete(`/orchestrator/messages/templates/${id}`)
},
previewTemplate(id, variables) {
return request.post(`/orchestrator/messages/templates/${id}/preview`, { variables })
}
}

View File

@@ -0,0 +1,71 @@
import { request } from '../index'
export default {
// Consent management
getConsent(userId) {
return request.get(`/compliance/consent/${userId}`)
},
updateConsent(userId, data) {
return request.put(`/compliance/consent/${userId}`, data)
},
recordConsent(data) {
return request.post('/compliance/consent/record', data)
},
// Privacy rights
requestDataExport(userId) {
return request.post('/compliance/privacy/export', { userId })
},
requestDataDeletion(userId, data) {
return request.post('/compliance/privacy/delete', {
userId,
...data
})
},
getPrivacyRequests(params) {
return request.get('/compliance/privacy/requests', { params })
},
// Audit logs
getAuditLogs(params) {
return request.get('/compliance/audit/logs', { params })
},
generateComplianceReport(data) {
return request.post('/compliance/audit/report', data)
},
// Regulatory compliance
getGDPRStatus() {
return request.get('/compliance/regulatory/gdpr/status')
},
getCCPAStatus() {
return request.get('/compliance/regulatory/ccpa/status')
},
// Data retention
getRetentionPolicies() {
return request.get('/compliance/retention/policies')
},
updateRetentionPolicy(type, data) {
return request.put(`/compliance/retention/policies/${type}`, data)
},
// Do Not Sell
getDoNotSellStatus(userId) {
return request.get(`/compliance/privacy/donotsell/${userId}`)
},
updateDoNotSellStatus(userId, optOut) {
return request.post('/compliance/privacy/donotsell', {
userId,
optOut
})
}
}

View File

@@ -0,0 +1,55 @@
import { request } from '../index'
export default {
// Get all scheduled campaigns
getAll(params) {
return request.get('/scheduled-campaigns', { params })
},
// Get scheduled campaign by ID
get(id) {
return request.get(`/scheduled-campaigns/${id}`)
},
// Create scheduled campaign
create(data) {
return request.post('/scheduled-campaigns', data)
},
// Update scheduled campaign
update(id, data) {
return request.put(`/scheduled-campaigns/${id}`, data)
},
// Delete scheduled campaign
delete(id) {
return request.delete(`/scheduled-campaigns/${id}`)
},
// Get campaign history
getHistory(id, limit = 50) {
return request.get(`/scheduled-campaigns/${id}/history`, {
params: { limit }
})
},
// Pause campaign
pause(id) {
return request.post(`/scheduled-campaigns/${id}/pause`)
},
// Resume campaign
resume(id) {
return request.post(`/scheduled-campaigns/${id}/resume`)
},
// Test campaign
test(id, options) {
return request.post(`/scheduled-campaigns/${id}/test`, options)
},
// Get statistics
getStatistics(period = '7d') {
return request.get(`/scheduled-campaigns/statistics/${period}`)
}
}

View File

@@ -0,0 +1,55 @@
import { request } from '../index'
export default {
// Get all segments
getAll(params) {
return request.get('/segments', { params })
},
// Get segment by ID
get(id) {
return request.get(`/segments/${id}`)
},
// Create segment
create(data) {
return request.post('/segments', data)
},
// Update segment
update(id, data) {
return request.put(`/segments/${id}`, data)
},
// Delete segment
delete(id) {
return request.delete(`/segments/${id}`)
},
// Test segment
test(id) {
return request.post(`/segments/${id}/test`)
},
// Get segment users
getUsers(id, params) {
return request.get(`/segments/${id}/users`, { params })
},
// Export segment users
export(id) {
return request.get(`/segments/${id}/export`, {
responseType: 'blob'
})
},
// Clone segment
clone(id, data) {
return request.post(`/segments/${id}/clone`, data)
},
// Get segment statistics
getStats() {
return request.get('/segments-stats')
}
}

View File

@@ -0,0 +1,45 @@
import request from '../index'
export default {
// Get user settings
get() {
return request({
url: '/settings',
method: 'get'
})
},
// Update user settings
update(data) {
return request({
url: '/settings',
method: 'put',
data
})
},
// Get API keys
getApiKeys() {
return request({
url: '/settings/api-keys',
method: 'get'
})
},
// Generate new API key
generateApiKey(data) {
return request({
url: '/settings/api-keys',
method: 'post',
data
})
},
// Delete API key
deleteApiKey(id) {
return request({
url: `/settings/api-keys/${id}`,
method: 'delete'
})
}
}

View File

@@ -0,0 +1,53 @@
import { request } from '../index'
export default {
// Get all templates
getAll(params) {
return request.get('/templates', { params })
},
// Get template by ID
get(id) {
return request.get(`/templates/${id}`)
},
// Create template
create(data) {
return request.post('/templates', data)
},
// Update template
update(id, data) {
return request.put(`/templates/${id}`, data)
},
// Delete template
delete(id) {
return request.delete(`/templates/${id}`)
},
// Preview template
preview(id, data) {
return request.post(`/templates/${id}/preview`, data)
},
// Test template
test(id, data) {
return request.post(`/templates/${id}/test`, data)
},
// Clone template
clone(id, data) {
return request.post(`/templates/${id}/clone`, data)
},
// Get template categories
getCategories() {
return request.get('/template-categories')
},
// Get template variables
getVariables() {
return request.get('/template-variables')
}
}

View File

@@ -0,0 +1,166 @@
import request from '@/utils/request'
export const tenantApi = {
// Get current tenant information
getCurrent() {
return request({
url: '/api/v1/tenants/current',
method: 'get'
})
},
// Update tenant basic information
update(data) {
return request({
url: '/api/v1/tenants/current',
method: 'patch',
data
})
},
// Update tenant settings
updateSettings(settings) {
return request({
url: '/api/v1/tenants/current/settings',
method: 'patch',
data: { settings }
})
},
// Update tenant branding
updateBranding(branding) {
return request({
url: '/api/v1/tenants/current/branding',
method: 'patch',
data: { branding }
})
},
// Update tenant compliance settings
updateCompliance(compliance) {
return request({
url: '/api/v1/tenants/current/compliance',
method: 'patch',
data: { compliance }
})
},
// Get tenant usage statistics
getUsage() {
return request({
url: '/api/v1/tenants/current/usage',
method: 'get'
})
},
// Get tenant billing information
getBilling() {
return request({
url: '/api/v1/tenants/current/billing',
method: 'get'
})
},
// List all tenants (superadmin only)
list(params) {
return request({
url: '/api/v1/tenants',
method: 'get',
params
})
},
// Get tenant by ID (superadmin only)
getById(id) {
return request({
url: `/api/v1/tenants/${id}`,
method: 'get'
})
},
// Update tenant by ID (superadmin only)
updateById(id, data) {
return request({
url: `/api/v1/tenants/${id}`,
method: 'patch',
data
})
},
// Delete tenant (superadmin only)
delete(id) {
return request({
url: `/api/v1/tenants/${id}`,
method: 'delete'
})
},
// Create new tenant (public)
signup(data) {
return request({
url: '/api/v1/tenants/signup',
method: 'post',
data
})
},
// Check if slug is available
checkSlug(slug) {
return request({
url: '/api/v1/tenants/check-slug',
method: 'get',
params: { slug }
})
},
// Upload tenant logo
uploadLogo(file) {
const formData = new FormData()
formData.append('logo', file)
return request({
url: '/api/v1/tenants/current/branding/logo',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// Get tenant features
getFeatures() {
return request({
url: '/api/v1/tenants/current/features',
method: 'get'
})
},
// Upgrade tenant plan
upgradePlan(plan, paymentMethod) {
return request({
url: '/api/v1/tenants/current/upgrade',
method: 'post',
data: { plan, paymentMethod }
})
},
// Get tenant audit logs
getAuditLogs(params) {
return request({
url: '/api/v1/tenants/current/audit-logs',
method: 'get',
params
})
},
// Export tenant data
exportData(format = 'json') {
return request({
url: '/api/v1/tenants/current/export',
method: 'get',
params: { format },
responseType: 'blob'
})
}
}

View File

@@ -0,0 +1,88 @@
<template>
<span class="animated-number">{{ displayValue }}</span>
</template>
<script setup>
import { ref, watch, computed, onMounted } from 'vue'
const props = defineProps({
value: {
type: Number,
required: true
},
format: {
type: Function,
default: (val) => val.toString()
},
duration: {
type: Number,
default: 1000
},
easing: {
type: String,
default: 'easeOutQuart'
}
})
const currentValue = ref(0)
let animationFrame = null
const displayValue = computed(() => {
return props.format(currentValue.value)
})
// Easing functions
const easingFunctions = {
linear: (t) => t,
easeOutQuart: (t) => 1 - Math.pow(1 - t, 4),
easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2
}
const animate = (fromValue, toValue) => {
const startTime = Date.now()
const endTime = startTime + props.duration
const easingFunction = easingFunctions[props.easing] || easingFunctions.easeOutQuart
const update = () => {
const now = Date.now()
const progress = Math.min((now - startTime) / props.duration, 1)
const easedProgress = easingFunction(progress)
currentValue.value = fromValue + (toValue - fromValue) * easedProgress
if (progress < 1) {
animationFrame = requestAnimationFrame(update)
} else {
currentValue.value = toValue
}
}
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
update()
}
watch(() => props.value, (newValue, oldValue) => {
animate(oldValue || 0, newValue)
}, { immediate: true })
onMounted(() => {
currentValue.value = props.value
})
</script>
<style scoped>
.animated-number {
transition: color 0.3s ease;
&.increasing {
color: #67c23a;
}
&.decreasing {
color: #f56c6c;
}
}
</style>

View File

@@ -0,0 +1,387 @@
<template>
<div class="performance-monitor">
<el-card class="monitor-card">
<template #header>
<div class="card-header">
<span>Performance Metrics</span>
<el-button type="primary" size="small" @click="refreshMetrics">
<el-icon><Refresh /></el-icon>
Refresh
</el-button>
</div>
</template>
<div class="metrics-grid">
<!-- Core Web Vitals -->
<div class="metric-card" :class="getMetricClass(metrics.lcp, lcpThresholds)">
<div class="metric-label">Largest Contentful Paint (LCP)</div>
<div class="metric-value">{{ formatTime(metrics.lcp) }}</div>
<div class="metric-target">Target: < 2.5s</div>
</div>
<div class="metric-card" :class="getMetricClass(metrics.fid, fidThresholds)">
<div class="metric-label">First Input Delay (FID)</div>
<div class="metric-value">{{ formatTime(metrics.fid, 'ms') }}</div>
<div class="metric-target">Target: < 100ms</div>
</div>
<div class="metric-card" :class="getMetricClass(metrics.cls, clsThresholds)">
<div class="metric-label">Cumulative Layout Shift (CLS)</div>
<div class="metric-value">{{ metrics.cls?.toFixed(3) || 'N/A' }}</div>
<div class="metric-target">Target: < 0.1</div>
</div>
<div class="metric-card" :class="getMetricClass(metrics.fcp, fcpThresholds)">
<div class="metric-label">First Contentful Paint (FCP)</div>
<div class="metric-value">{{ formatTime(metrics.fcp) }}</div>
<div class="metric-target">Target: < 1.8s</div>
</div>
<div class="metric-card">
<div class="metric-label">Time to First Byte (TTFB)</div>
<div class="metric-value">{{ formatTime(metrics.ttfb, 'ms') }}</div>
<div class="metric-target">Target: < 800ms</div>
</div>
<div class="metric-card">
<div class="metric-label">Time to Interactive (TTI)</div>
<div class="metric-value">{{ formatTime(metrics.tti) }}</div>
<div class="metric-target">Target: < 3.8s</div>
</div>
</div>
<!-- Memory Usage -->
<div class="section-title">Memory Usage</div>
<div class="memory-stats" v-if="memoryStats">
<el-progress
:percentage="memoryUsagePercentage"
:color="getProgressColor(memoryUsagePercentage)"
:stroke-width="20"
text-inside
>
<span>{{ formatBytes(memoryStats.usedJSHeapSize) }} / {{ formatBytes(memoryStats.jsHeapSizeLimit) }}</span>
</el-progress>
</div>
<!-- Resource Timing -->
<div class="section-title">Resource Load Times</div>
<el-table
:data="resourceTimings"
size="small"
max-height="300"
>
<el-table-column prop="name" label="Resource" width="300">
<template #default="{ row }">
<el-tooltip :content="row.fullName" placement="top">
<span class="resource-name">{{ row.name }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="type" label="Type" width="100" />
<el-table-column prop="size" label="Size" width="100">
<template #default="{ row }">
{{ formatBytes(row.size) }}
</template>
</el-table-column>
<el-table-column prop="duration" label="Duration" width="100">
<template #default="{ row }">
{{ row.duration }}ms
</template>
</el-table-column>
<el-table-column label="Timeline" min-width="200">
<template #default="{ row }">
<div class="timeline">
<div
class="timeline-bar"
:style="{
left: `${(row.startTime / maxTime) * 100}%`,
width: `${(row.duration / maxTime) * 100}%`,
backgroundColor: getResourceColor(row.type)
}"
/>
</div>
</template>
</el-table-column>
</el-table>
<!-- Actions -->
<div class="actions">
<el-button @click="clearCache">Clear Cache</el-button>
<el-button @click="runLighthouse">Run Lighthouse</el-button>
<el-button type="primary" @click="exportReport">Export Report</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { performanceMonitor } from '@/utils/performance'
import { clearAllCaches } from '@/utils/serviceWorker'
const metrics = ref({
fcp: null,
lcp: null,
fid: null,
cls: null,
ttfb: null,
tti: null
})
const memoryStats = ref(null)
const resourceTimings = ref([])
// Thresholds for Core Web Vitals
const lcpThresholds = { good: 2500, needsImprovement: 4000 }
const fidThresholds = { good: 100, needsImprovement: 300 }
const clsThresholds = { good: 0.1, needsImprovement: 0.25 }
const fcpThresholds = { good: 1800, needsImprovement: 3000 }
const memoryUsagePercentage = computed(() => {
if (!memoryStats.value) return 0
return Math.round((memoryStats.value.usedJSHeapSize / memoryStats.value.jsHeapSizeLimit) * 100)
})
const maxTime = computed(() => {
return Math.max(...resourceTimings.value.map(r => r.startTime + r.duration), 1)
})
function getMetricClass(value, thresholds) {
if (!value || !thresholds) return ''
if (value <= thresholds.good) return 'good'
if (value <= thresholds.needsImprovement) return 'needs-improvement'
return 'poor'
}
function getProgressColor(percentage) {
if (percentage < 50) return '#67c23a'
if (percentage < 80) return '#e6a23c'
return '#f56c6c'
}
function getResourceColor(type) {
const colors = {
script: '#409eff',
stylesheet: '#67c23a',
image: '#e6a23c',
font: '#909399',
fetch: '#f56c6c',
xhr: '#f56c6c'
}
return colors[type] || '#909399'
}
function formatTime(value, unit = 's') {
if (!value) return 'N/A'
if (unit === 'ms') {
return `${Math.round(value)}ms`
}
return `${(value / 1000).toFixed(2)}s`
}
function formatBytes(bytes) {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
async function refreshMetrics() {
// Get performance metrics
const perfMetrics = performanceMonitor.getMetrics()
metrics.value = perfMetrics
// Get memory stats
if (performance.memory) {
memoryStats.value = {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
}
}
// Get resource timings
const resources = performance.getEntriesByType('resource')
resourceTimings.value = resources
.filter(r => r.duration > 0)
.map(r => ({
name: r.name.split('/').pop() || r.name,
fullName: r.name,
type: getResourceType(r),
size: r.transferSize || 0,
duration: Math.round(r.duration),
startTime: Math.round(r.startTime)
}))
.sort((a, b) => b.duration - a.duration)
.slice(0, 20) // Top 20 slowest resources
}
function getResourceType(entry) {
const url = entry.name
if (url.match(/\.(js|mjs)$/)) return 'script'
if (url.match(/\.css$/)) return 'stylesheet'
if (url.match(/\.(png|jpg|jpeg|gif|svg|webp)$/)) return 'image'
if (url.match(/\.(woff|woff2|ttf|eot|otf)$/)) return 'font'
if (entry.initiatorType === 'fetch') return 'fetch'
if (entry.initiatorType === 'xmlhttprequest') return 'xhr'
return entry.initiatorType || 'other'
}
async function clearCache() {
try {
await clearAllCaches()
ElMessage.success('Cache cleared successfully')
} catch (error) {
ElMessage.error('Failed to clear cache')
}
}
async function runLighthouse() {
ElMessage.info('Lighthouse analysis started...')
// In real implementation, this would trigger a Lighthouse run
}
function exportReport() {
const report = {
timestamp: new Date().toISOString(),
metrics: metrics.value,
memory: memoryStats.value,
resources: resourceTimings.value
}
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `performance-report-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('Report exported successfully')
}
let refreshInterval
onMounted(() => {
refreshMetrics()
// Auto-refresh every 30 seconds
refreshInterval = setInterval(refreshMetrics, 30000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<style scoped>
.performance-monitor {
padding: 20px;
}
.monitor-card {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
padding: 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
text-align: center;
transition: all 0.3s;
}
.metric-card.good {
border-color: #67c23a;
background-color: #f0f9ff;
}
.metric-card.needs-improvement {
border-color: #e6a23c;
background-color: #fdf6ec;
}
.metric-card.poor {
border-color: #f56c6c;
background-color: #fef0f0;
}
.metric-label {
font-size: 12px;
color: #606266;
margin-bottom: 8px;
}
.metric-value {
font-size: 24px;
font-weight: bold;
color: #303133;
margin-bottom: 4px;
}
.metric-target {
font-size: 11px;
color: #909399;
}
.section-title {
font-size: 16px;
font-weight: 500;
margin: 24px 0 16px;
color: #303133;
}
.memory-stats {
margin-bottom: 24px;
}
.resource-name {
display: inline-block;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline {
position: relative;
width: 100%;
height: 20px;
background: #f5f7fa;
border-radius: 2px;
}
.timeline-bar {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 12px;
border-radius: 2px;
opacity: 0.8;
}
.actions {
margin-top: 24px;
text-align: center;
}
.actions .el-button {
margin: 0 8px;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div class="funnel-chart">
<div class="funnel-step" v-for="(step, index) in funnelSteps" :key="index">
<div
class="funnel-bar"
:style="{
width: `${step.percentage}%`,
backgroundColor: getStepColor(index)
}"
>
<div class="funnel-label">
<span class="step-name">{{ step.name }}</span>
<span class="step-value">{{ formatNumber(step.value) }}</span>
</div>
</div>
<div class="funnel-percentage">{{ step.percentage.toFixed(1) }}%</div>
<div v-if="index < funnelSteps.length - 1" class="conversion-rate">
<el-icon><ArrowDown /></el-icon>
{{ step.conversionRate.toFixed(1) }}%
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
const props = defineProps({
data: {
type: Array,
required: true
}
})
const funnelSteps = computed(() => {
if (!props.data || props.data.length === 0) return []
const maxValue = Math.max(...props.data.map(item => item.value))
return props.data.map((item, index) => {
const percentage = (item.value / maxValue) * 100
const conversionRate = index > 0
? (item.value / props.data[index - 1].value) * 100
: 100
return {
...item,
percentage,
conversionRate
}
})
})
const getStepColor = (index) => {
const colors = [
'#409eff',
'#67c23a',
'#e6a23c',
'#f56c6c',
'#909399'
]
return colors[index % colors.length]
}
const formatNumber = (value) => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`
} else if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`
}
return value.toString()
}
</script>
<style lang="scss" scoped>
.funnel-chart {
width: 100%;
padding: 20px;
.funnel-step {
margin-bottom: 20px;
position: relative;
.funnel-bar {
height: 50px;
border-radius: 4px;
position: relative;
transition: all 0.3s ease;
display: flex;
align-items: center;
padding: 0 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.funnel-label {
display: flex;
justify-content: space-between;
width: 100%;
color: white;
font-weight: 500;
.step-name {
font-size: 14px;
}
.step-value {
font-size: 16px;
font-weight: bold;
}
}
}
.funnel-percentage {
position: absolute;
right: -50px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
color: #606266;
font-weight: 500;
}
.conversion-rate {
text-align: center;
margin-top: 8px;
color: #909399;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
.el-icon {
font-size: 16px;
}
}
}
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div class="heatmap-chart">
<div class="heatmap-container">
<div class="y-axis">
<div v-for="day in days" :key="day" class="day-label">{{ day }}</div>
</div>
<div class="heatmap-grid">
<div class="x-axis">
<div v-for="hour in hours" :key="hour" class="hour-label">{{ hour }}</div>
</div>
<div class="cells">
<div
v-for="(cell, index) in heatmapCells"
:key="index"
class="heatmap-cell"
:style="{
backgroundColor: getCellColor(cell.value),
opacity: getCellOpacity(cell.value)
}"
@mouseenter="showTooltip($event, cell)"
@mouseleave="hideTooltip"
></div>
</div>
</div>
</div>
<div class="heatmap-legend">
<span class="legend-title">Activity Level:</span>
<div class="legend-gradient"></div>
<div class="legend-labels">
<span>Low</span>
<span>High</span>
</div>
</div>
<el-tooltip
v-model:visible="tooltipVisible"
:virtual-ref="tooltipRef"
placement="top"
:content="tooltipContent"
:popper-options="{
modifiers: [
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
}"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
data: {
type: Array,
required: true
}
})
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const hours = Array.from({ length: 24 }, (_, i) => i)
const tooltipVisible = ref(false)
const tooltipRef = ref(null)
const tooltipContent = ref('')
const heatmapCells = computed(() => {
const cells = []
for (let day = 0; day < 7; day++) {
for (let hour = 0; hour < 24; hour++) {
const dataPoint = props.data.find(d => d.day === day && d.hour === hour)
cells.push({
day,
hour,
value: dataPoint?.value || 0
})
}
}
return cells
})
const maxValue = computed(() => {
return Math.max(...props.data.map(d => d.value), 1)
})
const getCellColor = (value) => {
// Use a blue color scheme
return '#409eff'
}
const getCellOpacity = (value) => {
// Scale opacity based on value
const normalized = value / maxValue.value
return 0.1 + (normalized * 0.9)
}
const showTooltip = (event, cell) => {
tooltipRef.value = event.target
tooltipContent.value = `${days[cell.day]} ${cell.hour}:00 - Activity: ${cell.value}`
tooltipVisible.value = true
}
const hideTooltip = () => {
tooltipVisible.value = false
}
</script>
<style lang="scss" scoped>
.heatmap-chart {
width: 100%;
padding: 20px;
.heatmap-container {
display: flex;
gap: 10px;
.y-axis {
display: flex;
flex-direction: column;
justify-content: space-around;
padding-right: 10px;
.day-label {
font-size: 12px;
color: #606266;
height: 30px;
display: flex;
align-items: center;
}
}
.heatmap-grid {
flex: 1;
.x-axis {
display: flex;
justify-content: space-around;
margin-bottom: 5px;
.hour-label {
font-size: 11px;
color: #606266;
width: calc(100% / 24);
text-align: center;
}
}
.cells {
display: grid;
grid-template-columns: repeat(24, 1fr);
grid-template-rows: repeat(7, 1fr);
gap: 2px;
background-color: #f5f7fa;
padding: 2px;
border-radius: 4px;
.heatmap-cell {
aspect-ratio: 1;
border-radius: 2px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.2);
z-index: 1;
}
}
}
}
}
.heatmap-legend {
margin-top: 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #606266;
.legend-title {
font-weight: 500;
}
.legend-gradient {
width: 200px;
height: 10px;
background: linear-gradient(
to right,
rgba(64, 158, 255, 0.1),
rgba(64, 158, 255, 1)
);
border-radius: 5px;
}
.legend-labels {
display: flex;
justify-content: space-between;
width: 200px;
margin-left: -210px;
margin-top: 20px;
}
}
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<canvas ref="chartCanvas"></canvas>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue'
import {
Chart,
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Title,
Tooltip,
Legend,
Filler
)
const props = defineProps({
data: {
type: Object,
required: true
},
options: {
type: Object,
default: () => ({})
},
height: {
type: Number,
default: 300
}
})
const chartCanvas = ref(null)
let chartInstance = null
const createChart = () => {
if (chartInstance) {
chartInstance.destroy()
}
const ctx = chartCanvas.value.getContext('2d')
chartInstance = new Chart(ctx, {
type: 'line',
data: props.data,
options: {
responsive: true,
maintainAspectRatio: false,
...props.options
}
})
}
const updateChart = () => {
if (chartInstance) {
chartInstance.data = props.data
chartInstance.options = {
responsive: true,
maintainAspectRatio: false,
...props.options
}
chartInstance.update('active')
}
}
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, updateChart, { deep: true })
onMounted(() => {
chartCanvas.value.height = props.height
createChart()
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.destroy()
}
})
</script>
<style scoped>
canvas {
width: 100% !important;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<svg
:width="width"
:height="height"
class="sparkline"
:viewBox="`0 0 ${width} ${height}`"
>
<path
:d="sparklinePath"
fill="none"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
v-if="showDot && points.length > 0"
:cx="points[points.length - 1].x"
:cy="points[points.length - 1].y"
:r="3"
:fill="color"
/>
</svg>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
},
width: {
type: Number,
default: 100
},
height: {
type: Number,
default: 30
},
color: {
type: String,
default: '#409eff'
},
strokeWidth: {
type: Number,
default: 2
},
showDot: {
type: Boolean,
default: true
},
padding: {
type: Number,
default: 4
}
})
const points = computed(() => {
if (!props.data || props.data.length === 0) return []
const values = props.data.filter(v => typeof v === 'number')
if (values.length === 0) return []
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min || 1
const xStep = (props.width - props.padding * 2) / (values.length - 1 || 1)
const yScale = (props.height - props.padding * 2) / range
return values.map((value, index) => ({
x: props.padding + index * xStep,
y: props.padding + (max - value) * yScale
}))
})
const sparklinePath = computed(() => {
if (points.value.length === 0) return ''
const pathData = points.value.reduce((path, point, index) => {
if (index === 0) {
return `M ${point.x} ${point.y}`
}
return `${path} L ${point.x} ${point.y}`
}, '')
return pathData
})
</script>
<style scoped>
.sparkline {
display: block;
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div class="error-component">
<div class="error-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h3 class="error-title">{{ title }}</h3>
<p class="error-message">{{ message }}</p>
<el-button v-if="showRetry" type="primary" @click="$emit('retry')">
Retry
</el-button>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: 'Oops! Something went wrong'
},
message: {
type: String,
default: 'Failed to load the component. Please try again.'
},
showRetry: {
type: Boolean,
default: true
}
})
defineEmits(['retry'])
</script>
<style scoped>
.error-component {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 20px;
text-align: center;
}
.error-icon {
color: #f56c6c;
margin-bottom: 16px;
}
.error-title {
font-size: 18px;
font-weight: 500;
color: #303133;
margin: 0 0 8px;
}
.error-message {
font-size: 14px;
color: #606266;
margin: 0 0 16px;
max-width: 400px;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="loading-component">
<div class="loading-spinner">
<div class="spinner-ring">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
<p v-if="message" class="loading-message">{{ message }}</p>
</div>
</template>
<script setup>
defineProps({
message: {
type: String,
default: 'Loading...'
}
})
</script>
<style scoped>
.loading-component {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 20px;
}
.loading-spinner {
margin-bottom: 16px;
}
.spinner-ring {
display: inline-block;
position: relative;
width: 64px;
height: 64px;
}
.spinner-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 51px;
height: 51px;
margin: 6px;
border: 6px solid #409eff;
border-radius: 50%;
animation: spinner-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #409eff transparent transparent transparent;
}
.spinner-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.spinner-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.spinner-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes spinner-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-message {
color: #666;
font-size: 14px;
margin: 0;
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div ref="containerRef" class="virtual-list-container" @scroll="handleScroll">
<div class="virtual-list-spacer" :style="{ height: totalHeight + 'px' }">
<div
class="virtual-list-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="(item, index) in visibleItems"
:key="startIndex + index"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="startIndex + index" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { throttle } from '@/utils/performance'
const props = defineProps({
items: {
type: Array,
required: true
},
itemHeight: {
type: Number,
default: 50
},
buffer: {
type: Number,
default: 5
},
throttleDelay: {
type: Number,
default: 16 // ~60fps
}
})
const containerRef = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(0)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const startIndex = computed(() => {
return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer)
})
const endIndex = computed(() => {
return Math.min(
props.items.length,
Math.ceil((scrollTop.value + containerHeight.value) / props.itemHeight) + props.buffer
)
})
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value)
})
const offsetY = computed(() => startIndex.value * props.itemHeight)
const handleScroll = throttle((event) => {
scrollTop.value = event.target.scrollTop
}, props.throttleDelay)
const updateContainerHeight = () => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight
}
}
let resizeObserver = null
onMounted(() => {
updateContainerHeight()
// Observe container resize
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(updateContainerHeight)
resizeObserver.observe(containerRef.value)
}
// Handle window resize as fallback
window.addEventListener('resize', updateContainerHeight)
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
window.removeEventListener('resize', updateContainerHeight)
})
// Expose scroll methods
defineExpose({
scrollToIndex(index) {
if (containerRef.value) {
containerRef.value.scrollTop = index * props.itemHeight
}
},
scrollToTop() {
if (containerRef.value) {
containerRef.value.scrollTop = 0
}
},
scrollToBottom() {
if (containerRef.value) {
containerRef.value.scrollTop = totalHeight.value
}
}
})
</script>
<style scoped>
.virtual-list-container {
position: relative;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.virtual-list-spacer {
position: relative;
width: 100%;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
will-change: transform;
}
.virtual-list-item {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
}
/* Custom scrollbar styles */
.virtual-list-container::-webkit-scrollbar {
width: 8px;
}
.virtual-list-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.virtual-list-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.virtual-list-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="helper-reference">
<el-collapse v-model="activeNames">
<el-collapse-item
v-for="category in helperCategories"
:key="category.category"
:title="category.category"
:name="category.category"
>
<div v-for="helper in category.helpers" :key="helper.name" class="helper-item">
<h4>{{ helper.name }}</h4>
<div class="helper-syntax">
<code>{{ helper.syntax }}</code>
</div>
<p class="helper-description">{{ helper.description }}</p>
<div v-if="helper.examples" class="helper-examples">
<h5>Examples:</h5>
<div v-for="(example, index) in helper.examples" :key="index" class="example">
<code>{{ example }}</code>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const activeNames = ref([])
const helperCategories = ref([])
const loadHelpers = async () => {
try {
const response = await api.get('/api/v1/template-variables/helpers')
helperCategories.value = response.data.helpers
activeNames.value = [helperCategories.value[0]?.category]
} catch (error) {
console.error('Failed to load helpers:', error)
}
}
onMounted(() => {
loadHelpers()
})
</script>
<style scoped>
.helper-reference {
max-height: 500px;
overflow-y: auto;
}
.helper-item {
margin-bottom: 20px;
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
}
.helper-item h4 {
margin: 0 0 10px 0;
color: #303133;
}
.helper-syntax {
margin-bottom: 10px;
}
.helper-description {
margin: 10px 0;
color: #606266;
}
.helper-examples {
margin-top: 10px;
}
.helper-examples h5 {
margin: 0 0 5px 0;
font-size: 14px;
color: #909399;
}
.example {
margin: 5px 0;
}
code {
background: #fff;
padding: 4px 8px;
border-radius: 3px;
font-size: 13px;
color: #409eff;
border: 1px solid #dcdfe6;
display: inline-block;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div class="variable-help">
<el-collapse v-model="activeNames">
<el-collapse-item
v-for="category in variableCategories"
:key="category.category"
:title="category.category"
:name="category.category"
>
<el-table :data="category.variables" stripe>
<el-table-column prop="name" label="Variable" width="200">
<template #default="{ row }">
<code>{{ '{{' + row.name + '}}' }}</code>
</template>
</el-table-column>
<el-table-column prop="type" label="Type" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="Description" />
</el-table>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const activeNames = ref([])
const variableCategories = ref([])
const loadVariables = async () => {
try {
const response = await api.get('/api/v1/template-variables/available')
variableCategories.value = response.data.variables
activeNames.value = [variableCategories.value[0]?.category]
} catch (error) {
console.error('Failed to load variables:', error)
}
}
onMounted(() => {
loadVariables()
})
</script>
<style scoped>
.variable-help {
max-height: 500px;
overflow-y: auto;
}
code {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
color: #409eff;
}
</style>

View File

@@ -0,0 +1,171 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { throttle } from '@/utils/performance'
/**
* Infinite scroll composable for Vue 3
* Provides efficient infinite scrolling with performance optimizations
*/
export function useInfiniteScroll(options = {}) {
const {
threshold = 100,
throttleDelay = 200,
onLoadMore,
enabled = true
} = options
const loading = ref(false)
const finished = ref(false)
const error = ref(null)
const containerRef = ref(null)
let scrollHandler = null
const checkScroll = async () => {
if (!enabled || loading.value || finished.value || !containerRef.value) {
return
}
const container = containerRef.value
const scrollHeight = container.scrollHeight
const scrollTop = container.scrollTop
const clientHeight = container.clientHeight
if (scrollHeight - scrollTop - clientHeight <= threshold) {
loading.value = true
error.value = null
try {
const hasMore = await onLoadMore()
finished.value = !hasMore
} catch (err) {
error.value = err
console.error('Error loading more items:', err)
} finally {
loading.value = false
}
}
}
const reset = () => {
loading.value = false
finished.value = false
error.value = null
}
onMounted(() => {
if (containerRef.value) {
scrollHandler = throttle(checkScroll, throttleDelay)
containerRef.value.addEventListener('scroll', scrollHandler, { passive: true })
// Check initial state
checkScroll()
}
})
onUnmounted(() => {
if (containerRef.value && scrollHandler) {
containerRef.value.removeEventListener('scroll', scrollHandler)
}
})
return {
containerRef,
loading,
finished,
error,
reset,
checkScroll
}
}
/**
* Virtual infinite scroll composable
* Combines virtual scrolling with infinite scroll for maximum performance
*/
export function useVirtualInfiniteScroll(options = {}) {
const {
itemHeight = 50,
buffer = 5,
threshold = 100,
throttleDelay = 200,
onLoadMore,
items = ref([])
} = options
const containerRef = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(0)
const loading = ref(false)
const finished = ref(false)
// Calculate visible items
const visibleItems = computed(() => {
const startIndex = Math.floor(scrollTop.value / itemHeight)
const endIndex = Math.ceil((scrollTop.value + containerHeight.value) / itemHeight)
const start = Math.max(0, startIndex - buffer)
const end = Math.min(items.value.length, endIndex + buffer)
return {
items: items.value.slice(start, end),
startIndex: start,
endIndex: end,
offsetY: start * itemHeight
}
})
const totalHeight = computed(() => items.value.length * itemHeight)
const handleScroll = throttle(async (event) => {
const container = event.target
scrollTop.value = container.scrollTop
// Check if need to load more
const scrollHeight = container.scrollHeight
const clientHeight = container.clientHeight
if (scrollHeight - scrollTop.value - clientHeight <= threshold && !loading.value && !finished.value) {
loading.value = true
try {
const hasMore = await onLoadMore()
finished.value = !hasMore
} catch (error) {
console.error('Error loading more items:', error)
} finally {
loading.value = false
}
}
}, throttleDelay)
const updateContainerHeight = () => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight
}
}
onMounted(() => {
if (containerRef.value) {
updateContainerHeight()
containerRef.value.addEventListener('scroll', handleScroll, { passive: true })
// Update container height on resize
const resizeObserver = new ResizeObserver(updateContainerHeight)
resizeObserver.observe(containerRef.value)
}
})
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll)
}
})
return {
containerRef,
visibleItems,
totalHeight,
loading,
finished
}
}

View File

@@ -0,0 +1,43 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useResponsive() {
const isMobile = ref(false)
const isTablet = ref(false)
const isDesktop = ref(false)
const screenWidth = ref(window.innerWidth)
const screenHeight = ref(window.innerHeight)
// Breakpoints
const breakpoints = {
mobile: 768,
tablet: 1024,
desktop: 1280
}
const updateDeviceType = () => {
screenWidth.value = window.innerWidth
screenHeight.value = window.innerHeight
isMobile.value = screenWidth.value < breakpoints.mobile
isTablet.value = screenWidth.value >= breakpoints.mobile && screenWidth.value < breakpoints.desktop
isDesktop.value = screenWidth.value >= breakpoints.desktop
}
onMounted(() => {
updateDeviceType()
window.addEventListener('resize', updateDeviceType)
})
onUnmounted(() => {
window.removeEventListener('resize', updateDeviceType)
})
return {
isMobile,
isTablet,
isDesktop,
screenWidth,
screenHeight,
breakpoints
}
}

View File

@@ -0,0 +1,191 @@
import { ref, onUnmounted } from 'vue'
/**
* Web Worker composable for offloading heavy computations
*/
export function useWebWorker(workerScript) {
const worker = ref(null)
const loading = ref(false)
const error = ref(null)
const result = ref(null)
// Create worker
const createWorker = () => {
if (typeof Worker !== 'undefined') {
worker.value = new Worker(workerScript)
worker.value.onmessage = (event) => {
loading.value = false
result.value = event.data
}
worker.value.onerror = (err) => {
loading.value = false
error.value = err
console.error('Worker error:', err)
}
} else {
error.value = new Error('Web Workers not supported')
}
}
// Send message to worker
const postMessage = (data) => {
if (!worker.value) {
createWorker()
}
if (worker.value) {
loading.value = true
error.value = null
worker.value.postMessage(data)
}
}
// Terminate worker
const terminate = () => {
if (worker.value) {
worker.value.terminate()
worker.value = null
}
}
// Clean up on unmount
onUnmounted(() => {
terminate()
})
return {
postMessage,
terminate,
loading,
error,
result
}
}
/**
* Shared Worker composable for cross-tab communication
*/
export function useSharedWorker(workerScript, name) {
const worker = ref(null)
const port = ref(null)
const connected = ref(false)
const messages = ref([])
const connect = () => {
if (typeof SharedWorker !== 'undefined') {
worker.value = new SharedWorker(workerScript, name)
port.value = worker.value.port
port.value.onmessage = (event) => {
messages.value.push(event.data)
}
port.value.start()
connected.value = true
}
}
const sendMessage = (data) => {
if (port.value && connected.value) {
port.value.postMessage(data)
}
}
const disconnect = () => {
if (port.value) {
port.value.close()
port.value = null
connected.value = false
}
}
onUnmounted(() => {
disconnect()
})
return {
connect,
sendMessage,
disconnect,
connected,
messages
}
}
/**
* Create inline worker from function
*/
export function createInlineWorker(fn) {
const blob = new Blob([`(${fn.toString()})()`], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
const worker = new Worker(url)
// Clean up blob URL after worker is created
URL.revokeObjectURL(url)
return worker
}
/**
* Heavy computation worker utility
*/
export function useComputationWorker() {
const workerCode = `
self.onmessage = function(e) {
const { type, data } = e.data
switch(type) {
case 'sort':
const sorted = data.sort((a, b) => a - b)
self.postMessage({ type: 'sort', result: sorted })
break
case 'filter':
const filtered = data.items.filter(item => item[data.key] === data.value)
self.postMessage({ type: 'filter', result: filtered })
break
case 'aggregate':
const aggregated = data.reduce((acc, item) => {
acc[item.category] = (acc[item.category] || 0) + item.value
return acc
}, {})
self.postMessage({ type: 'aggregate', result: aggregated })
break
case 'search':
const searchResults = data.items.filter(item =>
item.toLowerCase().includes(data.query.toLowerCase())
)
self.postMessage({ type: 'search', result: searchResults })
break
default:
self.postMessage({ type: 'error', error: 'Unknown operation' })
}
}
`
const blob = new Blob([workerCode], { type: 'application/javascript' })
const workerUrl = URL.createObjectURL(blob)
const { postMessage, terminate, loading, error, result } = useWebWorker(workerUrl)
// Clean up blob URL
onUnmounted(() => {
URL.revokeObjectURL(workerUrl)
})
return {
sort: (data) => postMessage({ type: 'sort', data }),
filter: (items, key, value) => postMessage({ type: 'filter', data: { items, key, value } }),
aggregate: (data) => postMessage({ type: 'aggregate', data }),
search: (items, query) => postMessage({ type: 'search', data: { items, query } }),
terminate,
loading,
error,
result
}
}

View File

@@ -0,0 +1,154 @@
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search",
"filter": "Filter",
"export": "Export",
"import": "Import",
"refresh": "Refresh",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Info",
"yes": "Yes",
"no": "No",
"all": "All",
"none": "None",
"status": "Status",
"actions": "Actions",
"startTime": "Start Time",
"endTime": "End Time",
"createdAt": "Created At",
"updatedAt": "Updated At"
},
"menu": {
"dashboard": "Dashboard",
"campaigns": "Campaigns",
"analytics": "Analytics",
"abTesting": "A/B Testing",
"accounts": "Accounts",
"settings": "Settings",
"compliance": "Compliance"
},
"auth": {
"login": "Login",
"logout": "Logout",
"register": "Register",
"username": "Username",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"rememberMe": "Remember Me",
"forgotPassword": "Forgot Password?",
"loginSuccess": "Login successful",
"logoutSuccess": "Logout successful",
"registerSuccess": "Registration successful"
},
"dashboard": {
"title": "Dashboard",
"overview": "Overview",
"activeCampaigns": "Active Campaigns",
"totalMessages": "Total Messages",
"engagementRate": "Engagement Rate",
"conversionRate": "Conversion Rate",
"recentActivity": "Recent Activity",
"quickActions": "Quick Actions",
"createCampaign": "Create Campaign",
"viewAnalytics": "View Analytics",
"manageAccounts": "Manage Accounts"
},
"campaigns": {
"title": "Campaigns",
"list": "Campaign List",
"create": "Create Campaign",
"edit": "Edit Campaign",
"detail": "Campaign Detail",
"name": "Campaign Name",
"description": "Description",
"targetAudience": "Target Audience",
"messages": "Messages",
"schedule": "Schedule",
"goals": "Goals",
"status": {
"draft": "Draft",
"active": "Active",
"paused": "Paused",
"completed": "Completed",
"cancelled": "Cancelled"
},
"actions": {
"start": "Start",
"pause": "Pause",
"resume": "Resume",
"cancel": "Cancel",
"duplicate": "Duplicate"
}
},
"analytics": {
"title": "Analytics",
"overview": "Analytics Overview",
"metrics": "Metrics",
"impressions": "Impressions",
"clicks": "Clicks",
"conversions": "Conversions",
"engagement": "Engagement",
"timeRange": "Time Range",
"today": "Today",
"yesterday": "Yesterday",
"last7Days": "Last 7 Days",
"last30Days": "Last 30 Days",
"custom": "Custom Range"
},
"abTesting": {
"title": "A/B Testing",
"experiments": "Experiments",
"createExperiment": "Create Experiment",
"variants": "Variants",
"control": "Control",
"treatment": "Treatment",
"allocation": "Traffic Allocation",
"significance": "Statistical Significance",
"winner": "Winner",
"results": "Results"
},
"accounts": {
"title": "Accounts",
"telegram": "Telegram Accounts",
"add": "Add Account",
"phoneNumber": "Phone Number",
"active": "Active",
"inactive": "Inactive",
"groups": "Groups",
"syncGroups": "Sync Groups",
"lastSync": "Last Sync"
},
"settings": {
"title": "Settings",
"profile": "Profile",
"apiKeys": "API Keys",
"notifications": "Notifications",
"language": "Language",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"compliance": {
"title": "Compliance",
"consent": "Consent Management",
"privacy": "Privacy Rights",
"dataExport": "Data Export",
"dataDeletion": "Data Deletion",
"auditLogs": "Audit Logs",
"gdpr": "GDPR Compliance",
"ccpa": "CCPA Compliance",
"compliant": "Compliant",
"nonCompliant": "Non-Compliant"
}
}

View File

@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n'
import en from './en.json'
import zh from './zh.json'
const messages = {
en,
zh
}
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('locale') || 'en',
fallbackLocale: 'en',
messages
})
export default i18n

View File

@@ -0,0 +1,154 @@
{
"common": {
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"create": "创建",
"search": "搜索",
"filter": "筛选",
"export": "导出",
"import": "导入",
"refresh": "刷新",
"loading": "加载中...",
"success": "成功",
"error": "错误",
"warning": "警告",
"info": "信息",
"yes": "是",
"no": "否",
"all": "全部",
"none": "无",
"status": "状态",
"actions": "操作",
"startTime": "开始时间",
"endTime": "结束时间",
"createdAt": "创建时间",
"updatedAt": "更新时间"
},
"menu": {
"dashboard": "仪表盘",
"campaigns": "营销活动",
"analytics": "数据分析",
"abTesting": "A/B 测试",
"accounts": "账号管理",
"settings": "设置",
"compliance": "合规管理"
},
"auth": {
"login": "登录",
"logout": "退出",
"register": "注册",
"username": "用户名",
"email": "邮箱",
"password": "密码",
"confirmPassword": "确认密码",
"rememberMe": "记住我",
"forgotPassword": "忘记密码?",
"loginSuccess": "登录成功",
"logoutSuccess": "退出成功",
"registerSuccess": "注册成功"
},
"dashboard": {
"title": "仪表盘",
"overview": "概览",
"activeCampaigns": "活跃营销活动",
"totalMessages": "消息总数",
"engagementRate": "互动率",
"conversionRate": "转化率",
"recentActivity": "最近活动",
"quickActions": "快捷操作",
"createCampaign": "创建营销活动",
"viewAnalytics": "查看分析",
"manageAccounts": "管理账号"
},
"campaigns": {
"title": "营销活动",
"list": "活动列表",
"create": "创建活动",
"edit": "编辑活动",
"detail": "活动详情",
"name": "活动名称",
"description": "描述",
"targetAudience": "目标受众",
"messages": "消息",
"schedule": "计划",
"goals": "目标",
"status": {
"draft": "草稿",
"active": "进行中",
"paused": "已暂停",
"completed": "已完成",
"cancelled": "已取消"
},
"actions": {
"start": "开始",
"pause": "暂停",
"resume": "恢复",
"cancel": "取消",
"duplicate": "复制"
}
},
"analytics": {
"title": "数据分析",
"overview": "分析概览",
"metrics": "指标",
"impressions": "展示次数",
"clicks": "点击次数",
"conversions": "转化次数",
"engagement": "互动率",
"timeRange": "时间范围",
"today": "今天",
"yesterday": "昨天",
"last7Days": "最近7天",
"last30Days": "最近30天",
"custom": "自定义范围"
},
"abTesting": {
"title": "A/B 测试",
"experiments": "实验",
"createExperiment": "创建实验",
"variants": "变体",
"control": "对照组",
"treatment": "实验组",
"allocation": "流量分配",
"significance": "统计显著性",
"winner": "获胜者",
"results": "结果"
},
"accounts": {
"title": "账号管理",
"telegram": "Telegram 账号",
"add": "添加账号",
"phoneNumber": "手机号码",
"active": "活跃",
"inactive": "未激活",
"groups": "群组",
"syncGroups": "同步群组",
"lastSync": "最后同步"
},
"settings": {
"title": "设置",
"profile": "个人资料",
"apiKeys": "API 密钥",
"notifications": "通知",
"language": "语言",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统"
},
"compliance": {
"title": "合规管理",
"consent": "同意管理",
"privacy": "隐私权",
"dataExport": "数据导出",
"dataDeletion": "数据删除",
"auditLogs": "审计日志",
"gdpr": "GDPR 合规",
"ccpa": "CCPA 合规",
"compliant": "合规",
"nonCompliant": "不合规"
}
}

View File

@@ -0,0 +1,26 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import './style.css'
import './styles/mobile.css'
import App from './App.vue'
import router from './router'
import i18n from './locales'
const app = createApp(App)
const pinia = createPinia()
// Register all Element Plus icons
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.use(i18n)
app.mount('#app')

View File

@@ -0,0 +1,128 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import './style.css'
import App from './App.vue'
import router from './router'
import i18n from './locales'
// Performance utilities
import { performanceMonitor, setupImageLazyLoading, requestIdleCallback } from './utils/performance'
import { registerServiceWorker, setupNetworkListeners, requestNotificationPermission } from './utils/serviceWorker'
import { registerLazyLoadDirectives } from './plugins/lazyload'
import { createPersistedState } from './stores/plugins/persistedState'
const app = createApp(App)
const pinia = createPinia()
// Add persisted state plugin to Pinia
pinia.use(createPersistedState({
paths: ['user', 'settings', 'cache'],
debounceTime: 1000
}))
// Register Element Plus icons on-demand
const registerIcons = () => {
const icons = ['User', 'Lock', 'Message', 'Search', 'Plus', 'Delete', 'Edit', 'View', 'Download', 'Upload']
icons.forEach(name => {
if (ElementPlusIconsVue[name]) {
app.component(name, ElementPlusIconsVue[name])
}
})
}
// Register all icons in idle time for better performance
requestIdleCallback(() => {
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
if (!app.component(key)) {
app.component(key, component)
}
}
})
// Register initial required icons
registerIcons()
// Register lazy loading directives
registerLazyLoadDirectives(app)
// Global error handler
app.config.errorHandler = (err, instance, info) => {
console.error('Global error:', err, info)
// Send error to monitoring service
performanceMonitor.reportError({
error: err.toString(),
componentInfo: info,
stack: err.stack,
timestamp: new Date().toISOString()
})
}
// Global performance config
app.config.performance = true
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
// Element Plus performance options
size: 'default',
zIndex: 3000
})
app.use(i18n)
// Mount app
app.mount('#app')
// Post-mount optimizations
requestIdleCallback(async () => {
// Initialize performance monitoring
performanceMonitor.monitorLongTasks()
// Report initial metrics after 5 seconds
setTimeout(() => {
performanceMonitor.reportMetrics()
}, 5000)
// Setup image lazy loading
setupImageLazyLoading()
// Register service worker
if (import.meta.env.PROD) {
const registration = await registerServiceWorker()
// Request notification permission
if (registration) {
await requestNotificationPermission()
}
}
// Setup network listeners
setupNetworkListeners({
onOnline: () => {
// Sync data when back online
window.dispatchEvent(new CustomEvent('app:online'))
},
onOffline: () => {
// Show offline notification
window.dispatchEvent(new CustomEvent('app:offline'))
}
})
// Preload critical routes
const criticalRoutes = ['/dashboard', '/campaigns', '/users']
criticalRoutes.forEach(route => {
router.resolve(route)
})
})
// Development helpers
if (import.meta.env.DEV) {
// Performance debugging
window.__PERFORMANCE__ = performanceMonitor
// Enable Vue devtools
app.config.devtools = true
}

View File

@@ -0,0 +1,236 @@
// Vue 3 lazy loading directive for images and components
/**
* Lazy load directive for images
*/
export const LazyLoadDirective = {
mounted(el, binding) {
const options = {
root: null,
rootMargin: '0px',
threshold: 0.1
}
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
const src = binding.value
// Create a new image to preload
const tempImg = new Image()
tempImg.onload = () => {
// Add fade-in animation
img.style.opacity = '0'
img.src = src
img.style.transition = 'opacity 0.3s'
// Force reflow
img.offsetHeight
img.style.opacity = '1'
img.classList.add('lazy-loaded')
// Emit custom event
img.dispatchEvent(new CustomEvent('lazyloaded'))
}
tempImg.onerror = () => {
// Use placeholder on error
img.src = binding.arg || '/images/placeholder.png'
img.classList.add('lazy-error')
// Emit error event
img.dispatchEvent(new CustomEvent('lazyerror'))
}
tempImg.src = src
observer.unobserve(img)
}
})
}, options)
// Start observing
imageObserver.observe(el)
// Store observer for cleanup
el._imageObserver = imageObserver
},
unmounted(el) {
if (el._imageObserver) {
el._imageObserver.disconnect()
delete el._imageObserver
}
}
}
/**
* Lazy load directive for background images
*/
export const LazyBackgroundDirective = {
mounted(el, binding) {
const options = {
root: null,
rootMargin: '50px',
threshold: 0.01
}
const bgObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target
const src = binding.value
// Preload image
const tempImg = new Image()
tempImg.onload = () => {
element.style.backgroundImage = `url(${src})`
element.classList.add('lazy-bg-loaded')
}
tempImg.src = src
observer.unobserve(element)
}
})
}, options)
// Add loading class
el.classList.add('lazy-bg-loading')
// Start observing
bgObserver.observe(el)
// Store observer for cleanup
el._bgObserver = bgObserver
},
unmounted(el) {
if (el._bgObserver) {
el._bgObserver.disconnect()
delete el._bgObserver
}
}
}
/**
* Progressive image loading
*/
export const ProgressiveImageDirective = {
mounted(el, binding) {
const { lowQuality, highQuality } = binding.value
// Load low quality first
el.src = lowQuality
el.classList.add('progressive-loading')
// Create intersection observer for high quality
const options = {
root: null,
rootMargin: '50px',
threshold: 0.01
}
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
const highQualityImg = new Image()
highQualityImg.onload = () => {
img.src = highQuality
img.classList.remove('progressive-loading')
img.classList.add('progressive-loaded')
}
highQualityImg.src = highQuality
observer.unobserve(img)
}
})
}, options)
observer.observe(el)
el._progressiveObserver = observer
},
unmounted(el) {
if (el._progressiveObserver) {
el._progressiveObserver.disconnect()
delete el._progressiveObserver
}
}
}
/**
* Lazy component loader
*/
export function createLazyComponent(loader, options = {}) {
const {
loadingComponent = null,
errorComponent = null,
delay = 200,
timeout = 10000,
suspensible = false
} = options
return {
loader,
loadingComponent,
errorComponent,
delay,
timeout,
suspensible,
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
// Retry up to 3 times
setTimeout(retry, 1000 * attempts)
} else {
console.error('Failed to load component after 3 attempts:', error)
fail()
}
}
}
}
/**
* Register all lazy loading directives
*/
export function registerLazyLoadDirectives(app) {
app.directive('lazy', LazyLoadDirective)
app.directive('lazy-bg', LazyBackgroundDirective)
app.directive('progressive', ProgressiveImageDirective)
}
/**
* Preload images utility
*/
export function preloadImages(urls) {
const promises = urls.map(url => {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(url)
img.onerror = () => reject(new Error(`Failed to load image: ${url}`))
img.src = url
})
})
return Promise.allSettled(promises)
}
/**
* Create responsive image set
*/
export function createResponsiveImageSet(baseUrl, sizes = [320, 640, 1024, 1920]) {
const srcSet = sizes.map(size => {
const url = baseUrl.replace(/(\.[^.]+)$/, `-${size}w$1`)
return `${url} ${size}w`
}).join(', ')
return {
src: baseUrl,
srcSet,
sizes: '(max-width: 320px) 320px, (max-width: 640px) 640px, (max-width: 1024px) 1024px, 1920px'
}
}

View File

@@ -0,0 +1,197 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/login-debug',
name: 'LoginDebug',
component: () => import('@/views/LoginDebug.vue'),
meta: { requiresAuth: false }
},
{
path: '/test',
name: 'TestSimple',
component: () => import('@/views/TestSimple.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
name: 'Home',
component: () => import('@/views/HomeTest.vue'),
meta: { requiresAuth: true }
},
{
path: '/dashboard',
component: () => import('@/views/LayoutSimple.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/DashboardEnhanced.vue')
},
{
path: 'campaigns',
name: 'Campaigns',
component: () => import('@/views/campaigns/CampaignList.vue')
},
{
path: 'campaigns/create',
name: 'CreateCampaign',
component: () => import('@/views/campaigns/CreateCampaign.vue')
},
{
path: 'campaigns/:id',
name: 'CampaignDetail',
component: () => import('@/views/campaigns/CampaignDetail.vue')
},
{
path: 'analytics',
name: 'Analytics',
component: () => import('@/views/Analytics.vue')
},
{
path: 'ab-testing',
name: 'ABTesting',
component: () => import('@/views/ABTesting.vue')
},
{
path: 'accounts',
name: 'Accounts',
component: () => import('@/views/AccountsEnhanced.vue')
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Settings.vue')
},
{
path: 'compliance',
name: 'Compliance',
component: () => import('@/views/Compliance.vue')
},
{
path: 'data-exchange',
name: 'DataExchange',
component: () => import('@/views/data-exchange/DataExchange.vue')
},
{
path: 'workflows',
name: 'Workflows',
component: () => import('@/views/workflow/WorkflowList.vue')
},
{
path: 'workflow/:id/edit',
name: 'WorkflowEdit',
component: () => import('@/views/workflow/WorkflowEditor.vue')
},
{
path: 'workflow/:id/instances',
name: 'WorkflowInstances',
component: () => import('@/views/workflow/WorkflowInstances.vue')
},
{
path: 'analytics/realtime',
name: 'RealtimeAnalytics',
component: () => import('@/views/analytics/RealtimeDashboard.vue')
},
{
path: 'analytics/reports',
name: 'AnalyticsReports',
component: () => import('@/views/analytics/Reports.vue')
},
{
path: 'webhooks',
name: 'Webhooks',
component: () => import('@/views/webhooks/WebhookList.vue')
},
{
path: 'templates',
name: 'Templates',
component: () => import('@/views/templates/TemplateList.vue')
},
{
path: 'translations',
name: 'Translations',
component: () => import('@/views/i18n/TranslationManager.vue')
},
{
path: 'users',
name: 'UserManagement',
component: () => import('@/views/users/UserManagement.vue')
},
{
path: 'campaigns/schedules',
name: 'ScheduledCampaigns',
component: () => import('@/views/campaigns/ScheduledCampaigns.vue')
},
{
path: 'campaigns/schedule/new',
name: 'CreateSchedule',
component: () => import('@/views/campaigns/CampaignScheduler.vue')
},
{
path: 'campaigns/schedule/:id',
name: 'EditSchedule',
component: () => import('@/views/campaigns/CampaignScheduler.vue')
},
{
path: 'tenant/settings',
name: 'TenantSettings',
component: () => import('@/views/tenant/TenantSettings.vue'),
meta: { requiresRole: 'admin' }
},
{
path: 'tenants',
name: 'TenantList',
component: () => import('@/views/tenant/TenantList.vue'),
meta: { requiresRole: 'superadmin' }
},
{
path: 'billing',
name: 'Billing',
component: () => import('@/views/billing/BillingDashboard.vue')
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Navigation guard
router.beforeEach((to, from, next) => {
console.log('Navigation:', from.path, '->', to.path)
// Check if user is authenticated by looking at token in localStorage
const token = localStorage.getItem('token')
const isAuthenticated = !!token
console.log('Auth check:', { isAuthenticated, requiresAuth: to.meta.requiresAuth })
if (to.meta.requiresAuth && !isAuthenticated) {
console.log('Redirecting to login...')
next({ name: 'Login', query: { redirect: to.fullPath } })
} else if (to.name === 'Login' && isAuthenticated) {
console.log('User already logged in, redirecting to home...')
next({ name: 'Home' })
} else {
console.log('Proceeding to route...')
next()
}
})
export default router

View File

@@ -0,0 +1,249 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/api'
export const useABTestingStore = defineStore('abTesting', () => {
// State
const experiments = ref([])
const currentExperiment = ref(null)
const experimentStatus = ref(null)
const loading = ref(false)
// Actions
const fetchExperiments = async (params = {}) => {
try {
const response = await api.get('/api/experiments', { params })
experiments.value = response.data.experiments
return response.data
} catch (error) {
console.error('Failed to fetch experiments:', error)
throw error
}
}
const fetchExperiment = async (experimentId) => {
try {
loading.value = true
const response = await api.get(`/api/experiments/${experimentId}`)
currentExperiment.value = response.data
return response.data
} catch (error) {
console.error('Failed to fetch experiment:', error)
throw error
} finally {
loading.value = false
}
}
const createExperiment = async (experimentData) => {
try {
const response = await api.post('/api/experiments', experimentData)
return response.data
} catch (error) {
console.error('Failed to create experiment:', error)
throw error
}
}
const updateExperiment = async (experimentId, updates) => {
try {
const response = await api.put(`/api/experiments/${experimentId}`, updates)
currentExperiment.value = response.data
return response.data
} catch (error) {
console.error('Failed to update experiment:', error)
throw error
}
}
const deleteExperiment = async (experimentId) => {
try {
await api.delete(`/api/experiments/${experimentId}`)
} catch (error) {
console.error('Failed to delete experiment:', error)
throw error
}
}
const startExperiment = async (experimentId) => {
try {
const response = await api.post(`/api/experiments/${experimentId}/start`)
return response.data
} catch (error) {
console.error('Failed to start experiment:', error)
throw error
}
}
const stopExperiment = async (experimentId, reason) => {
try {
const response = await api.post(`/api/experiments/${experimentId}/stop`, { reason })
return response.data
} catch (error) {
console.error('Failed to stop experiment:', error)
throw error
}
}
const fetchExperimentStatus = async (experimentId) => {
try {
const response = await api.get(`/api/experiments/${experimentId}/status`)
experimentStatus.value = response.data
return response.data
} catch (error) {
console.error('Failed to fetch experiment status:', error)
throw error
}
}
// Tracking APIs
const allocateUser = async (experimentId, userId, userContext = {}) => {
try {
const response = await api.post('/api/tracking/allocate', {
experimentId,
userId,
userContext
})
return response.data
} catch (error) {
console.error('Failed to allocate user:', error)
throw error
}
}
const recordConversion = async (experimentId, userId, value = 1, metadata = {}) => {
try {
const response = await api.post('/api/tracking/convert', {
experimentId,
userId,
value,
metadata
})
return response.data
} catch (error) {
console.error('Failed to record conversion:', error)
throw error
}
}
const batchAllocate = async (experimentId, users) => {
try {
const response = await api.post('/api/tracking/batch/allocate', {
experimentId,
users
})
return response.data
} catch (error) {
console.error('Failed to batch allocate users:', error)
throw error
}
}
const batchRecordConversions = async (experimentId, conversions) => {
try {
const response = await api.post('/api/tracking/batch/convert', {
experimentId,
conversions
})
return response.data
} catch (error) {
console.error('Failed to batch record conversions:', error)
throw error
}
}
// Results APIs
const fetchExperimentResults = async (experimentId) => {
try {
const response = await api.get(`/api/results/${experimentId}`)
return response.data
} catch (error) {
console.error('Failed to fetch experiment results:', error)
throw error
}
}
const fetchVariantDetails = async (experimentId, variantId) => {
try {
const response = await api.get(`/api/results/${experimentId}/variants/${variantId}`)
return response.data
} catch (error) {
console.error('Failed to fetch variant details:', error)
throw error
}
}
const fetchSegmentAnalysis = async (experimentId) => {
try {
const response = await api.get(`/api/results/${experimentId}/segments`)
return response.data
} catch (error) {
console.error('Failed to fetch segment analysis:', error)
throw error
}
}
const exportResults = async (experimentId, format = 'csv') => {
try {
const response = await api.get(`/api/results/${experimentId}/export`, {
params: { format },
responseType: 'blob'
})
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `experiment-${experimentId}-results.${format}`)
document.body.appendChild(link)
link.click()
link.remove()
return true
} catch (error) {
console.error('Failed to export results:', error)
throw error
}
}
// Reset store
const reset = () => {
experiments.value = []
currentExperiment.value = null
experimentStatus.value = null
loading.value = false
}
return {
// State
experiments,
currentExperiment,
experimentStatus,
loading,
// Actions
fetchExperiments,
fetchExperiment,
createExperiment,
updateExperiment,
deleteExperiment,
startExperiment,
stopExperiment,
fetchExperimentStatus,
// Tracking
allocateUser,
recordConversion,
batchAllocate,
batchRecordConversions,
// Results
fetchExperimentResults,
fetchVariantDetails,
fetchSegmentAnalysis,
exportResults,
// Utils
reset
}
})

View File

@@ -0,0 +1,102 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
import router from '@/router'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(localStorage.getItem('token') || '')
const loading = ref(false)
const isAuthenticated = computed(() => !!token.value)
async function login(credentials) {
loading.value = true
try {
const response = await api.auth.login(credentials)
const { data } = response
token.value = data.accessToken
user.value = data.user
localStorage.setItem('token', data.accessToken)
localStorage.setItem('refreshToken', data.refreshToken)
api.setAuthToken(data.accessToken)
return { success: true }
} catch (error) {
return {
success: false,
message: error.response?.data?.error || 'Login failed'
}
} finally {
loading.value = false
}
}
async function register(userData) {
loading.value = true
try {
const response = await api.auth.register(userData)
const { token: newToken, user: newUser } = response.data
token.value = newToken
user.value = newUser
localStorage.setItem('token', newToken)
api.setAuthToken(newToken)
return { success: true }
} catch (error) {
return {
success: false,
message: error.response?.data?.error || 'Registration failed'
}
} finally {
loading.value = false
}
}
async function logout() {
try {
await api.auth.logout()
} catch (error) {
console.error('Logout error:', error)
} finally {
token.value = ''
user.value = null
localStorage.removeItem('token')
api.setAuthToken('')
router.push({ name: 'Login' })
}
}
async function fetchProfile() {
if (!token.value) return
try {
const response = await api.auth.getProfile()
user.value = response.data.user
} catch (error) {
console.error('Failed to fetch profile:', error)
if (error.response?.status === 401) {
await logout()
}
}
}
// Initialize auth token
if (token.value) {
api.setAuthToken(token.value)
fetchProfile()
}
return {
user,
token,
loading,
isAuthenticated,
login,
register,
logout,
fetchProfile
}
})

View File

@@ -0,0 +1,268 @@
// Pinia plugin for persisted state with performance optimizations
import { debounce } from '@/utils/performance'
const STORAGE_KEY_PREFIX = 'marketing_agent_'
/**
* Create persisted state plugin for Pinia
*/
export function createPersistedState(options = {}) {
const {
storage = localStorage,
paths = [],
debounceTime = 1000,
serializer = JSON,
filter = () => true
} = options
return (context) => {
const { store } = context
const storeId = store.$id
const storageKey = STORAGE_KEY_PREFIX + storeId
// Load initial state
try {
const savedState = storage.getItem(storageKey)
if (savedState) {
const parsed = serializer.parse(savedState)
// Only restore specified paths or all if paths is empty
if (paths.length === 0) {
store.$patch(parsed)
} else {
const filtered = {}
paths.forEach(path => {
const keys = path.split('.')
let source = parsed
let target = filtered
keys.forEach((key, index) => {
if (index === keys.length - 1) {
if (source && key in source) {
target[key] = source[key]
}
} else {
if (!target[key]) target[key] = {}
target = target[key]
source = source ? source[key] : undefined
}
})
})
store.$patch(filtered)
}
}
} catch (error) {
console.error(`Failed to load persisted state for ${storeId}:`, error)
}
// Save state changes with debouncing
const saveState = debounce(() => {
try {
const state = store.$state
let toSave = state
// Filter state if paths are specified
if (paths.length > 0) {
toSave = {}
paths.forEach(path => {
const keys = path.split('.')
let source = state
let target = toSave
keys.forEach((key, index) => {
if (index === keys.length - 1) {
if (source && key in source) {
target[key] = source[key]
}
} else {
if (!target[key]) target[key] = {}
target = target[key]
source = source ? source[key] : undefined
}
})
})
}
// Apply custom filter
if (filter(toSave)) {
storage.setItem(storageKey, serializer.stringify(toSave))
}
} catch (error) {
console.error(`Failed to save state for ${storeId}:`, error)
}
}, debounceTime)
// Subscribe to state changes
store.$subscribe((mutation, state) => {
saveState()
})
// Subscribe to actions for immediate save on specific actions
store.$onAction(({ name, after }) => {
// Save immediately after logout or critical actions
if (['logout', 'clearData', 'resetStore'].includes(name)) {
after(() => {
saveState.cancel()
try {
if (name === 'logout' || name === 'clearData') {
storage.removeItem(storageKey)
} else {
storage.setItem(storageKey, serializer.stringify(store.$state))
}
} catch (error) {
console.error(`Failed to handle action ${name}:`, error)
}
})
}
})
}
}
/**
* Secure storage wrapper with encryption
*/
export class SecureStorage {
constructor(secretKey) {
this.secretKey = secretKey
}
async encrypt(data) {
const encoder = new TextEncoder()
const dataBuffer = encoder.encode(JSON.stringify(data))
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(this.secretKey),
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
dataBuffer
)
const combined = new Uint8Array(iv.length + encrypted.byteLength)
combined.set(iv)
combined.set(new Uint8Array(encrypted), iv.length)
return btoa(String.fromCharCode(...combined))
}
async decrypt(encryptedData) {
const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0))
const iv = combined.slice(0, 12)
const encrypted = combined.slice(12)
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(this.secretKey),
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
encrypted
)
const decoder = new TextDecoder()
return JSON.parse(decoder.decode(decrypted))
}
setItem(key, value) {
return this.encrypt(value).then(encrypted => {
localStorage.setItem(key, encrypted)
})
}
getItem(key) {
const encrypted = localStorage.getItem(key)
if (!encrypted) return Promise.resolve(null)
return this.decrypt(encrypted).catch(error => {
console.error('Decryption failed:', error)
localStorage.removeItem(key)
return null
})
}
removeItem(key) {
localStorage.removeItem(key)
}
}
/**
* IndexedDB storage for large data
*/
export class IndexedDBStorage {
constructor(dbName = 'marketing_agent_store', storeName = 'state') {
this.dbName = dbName
this.storeName = storeName
this.db = null
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName)
}
}
})
}
async setItem(key, value) {
if (!this.db) await this.init()
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite')
const store = transaction.objectStore(this.storeName)
const request = store.put(value, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async getItem(key) {
if (!this.db) await this.init()
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly')
const store = transaction.objectStore(this.storeName)
const request = store.get(key)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
async removeItem(key) {
if (!this.db) await this.init()
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite')
const store = transaction.objectStore(this.storeName)
const request = store.delete(key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
}

View File

@@ -0,0 +1,84 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--el-color-primary: #06b6d4;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Element Plus customizations */
.el-button--primary {
--el-button-bg-color: #06b6d4;
--el-button-border-color: #06b6d4;
--el-button-hover-bg-color: #0891b2;
--el-button-hover-border-color: #0891b2;
}
/* Chart container */
.chart-container {
position: relative;
height: 100%;
width: 100%;
}
/* Loading animation */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(6, 182, 212, 0.3);
border-radius: 50%;
border-top-color: #06b6d4;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

View File

@@ -0,0 +1,192 @@
/* Mobile-specific styles for Marketing Agent System */
/* Ensure full height on mobile browsers */
.h-full {
height: 100vh;
height: -webkit-fill-available;
}
/* Fix iOS Safari bottom bar issue */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
/* Mobile-optimized form elements */
@media (max-width: 768px) {
/* Larger touch targets */
.el-button {
min-height: 44px;
}
.el-input__inner {
height: 44px !important;
font-size: 16px !important; /* Prevent zoom on iOS */
}
.el-select .el-input__inner {
height: 44px !important;
}
/* Better spacing for mobile */
.el-form-item {
margin-bottom: 20px;
}
/* Mobile-friendly tables */
.el-table {
font-size: 14px;
}
.el-table .cell {
padding: 8px 10px;
}
/* Responsive cards */
.el-card {
margin-bottom: 12px;
}
.el-card__body {
padding: 12px;
}
/* Mobile dialogs */
.el-dialog {
width: 90% !important;
margin-top: 10vh !important;
}
.el-dialog__body {
padding: 15px;
}
/* Mobile message boxes */
.el-message-box {
width: 80%;
}
/* Mobile dropdowns */
.el-dropdown-menu {
min-width: 150px;
}
/* Bottom navigation spacing */
.pb-20 {
padding-bottom: 5rem;
}
}
/* Smooth scrolling */
.scroll-smooth {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
/* Prevent text selection on interactive elements */
.no-select {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Mobile tap highlight */
.tap-highlight {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
}
/* Loading states */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Pull to refresh indicator */
.pull-to-refresh {
position: absolute;
top: -50px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: top 0.3s;
}
.pull-to-refresh.active {
top: 10px;
}
/* Mobile-optimized animations */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Swipe actions */
.swipe-action {
position: relative;
overflow: hidden;
touch-action: pan-y;
}
.swipe-action-content {
position: relative;
background: white;
z-index: 2;
transition: transform 0.3s;
}
.swipe-action-buttons {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
}
/* Mobile-friendly tooltips */
@media (max-width: 768px) {
.el-tooltip__popper {
max-width: 250px;
font-size: 12px;
}
}
/* Responsive images */
.responsive-img {
max-width: 100%;
height: auto;
display: block;
}
/* Mobile grid adjustments */
@media (max-width: 640px) {
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,34 @@
import { format, parseISO } from 'date-fns'
export function formatDateTime(dateString) {
if (!dateString) return '-'
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : dateString
return format(date, 'yyyy-MM-dd HH:mm:ss')
} catch (error) {
console.error('Date formatting error:', error)
return dateString
}
}
export function formatDate(dateString) {
if (!dateString) return '-'
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : dateString
return format(date, 'yyyy-MM-dd')
} catch (error) {
console.error('Date formatting error:', error)
return dateString
}
}
export function formatTime(dateString) {
if (!dateString) return '-'
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : dateString
return format(date, 'HH:mm:ss')
} catch (error) {
console.error('Date formatting error:', error)
return dateString
}
}

View File

@@ -0,0 +1,321 @@
// Performance monitoring and optimization utilities
/**
* Performance Observer for monitoring various metrics
*/
export class PerformanceMonitor {
constructor() {
this.metrics = {
fcp: null, // First Contentful Paint
lcp: null, // Largest Contentful Paint
fid: null, // First Input Delay
cls: null, // Cumulative Layout Shift
ttfb: null, // Time to First Byte
tti: null, // Time to Interactive
resourceTimings: []
}
this.initializeObservers()
}
initializeObservers() {
// Web Vitals Observer
if ('PerformanceObserver' in window) {
// LCP Observer
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
this.metrics.lcp = lastEntry.renderTime || lastEntry.loadTime
})
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
// FID Observer
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach((entry) => {
this.metrics.fid = entry.processingStart - entry.startTime
})
})
fidObserver.observe({ entryTypes: ['first-input'] })
// CLS Observer
let clsValue = 0
let clsEntries = []
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
clsEntries.push(entry)
}
}
this.metrics.cls = clsValue
})
clsObserver.observe({ entryTypes: ['layout-shift'] })
// Navigation Timing
if (window.performance && window.performance.timing) {
const timing = window.performance.timing
this.metrics.ttfb = timing.responseStart - timing.fetchStart
}
}
}
/**
* Get current performance metrics
*/
getMetrics() {
// Get FCP from performance API
const paintMetrics = performance.getEntriesByType('paint')
const fcp = paintMetrics.find(metric => metric.name === 'first-contentful-paint')
if (fcp) {
this.metrics.fcp = fcp.startTime
}
return this.metrics
}
/**
* Report metrics to analytics service
*/
async reportMetrics() {
const metrics = this.getMetrics()
try {
await fetch('/api/v1/analytics/performance', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
metrics
})
})
} catch (error) {
console.error('Failed to report performance metrics:', error)
}
}
/**
* Monitor long tasks
*/
monitorLongTasks() {
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long task detected:', {
duration: entry.duration,
startTime: entry.startTime,
name: entry.name
})
}
})
observer.observe({ entryTypes: ['longtask'] })
}
}
}
/**
* Lazy loading utility for components
*/
export function lazyLoadComponent(componentPath) {
return () => ({
component: import(componentPath),
loading: () => import('@/components/common/LoadingComponent.vue'),
error: () => import('@/components/common/ErrorComponent.vue'),
delay: 200,
timeout: 10000
})
}
/**
* Debounce function for performance optimization
*/
export function debounce(func, wait, immediate = false) {
let timeout
return function executedFunction(...args) {
const later = () => {
timeout = null
if (!immediate) func(...args)
}
const callNow = immediate && !timeout
clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func(...args)
}
}
/**
* Throttle function for performance optimization
*/
export function throttle(func, limit) {
let inThrottle
return function(...args) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}
/**
* Virtual scrolling helper
*/
export class VirtualScroller {
constructor(options) {
this.itemHeight = options.itemHeight
this.containerHeight = options.containerHeight
this.items = options.items || []
this.buffer = options.buffer || 5
}
getVisibleItems(scrollTop) {
const startIndex = Math.floor(scrollTop / this.itemHeight)
const endIndex = Math.ceil((scrollTop + this.containerHeight) / this.itemHeight)
const visibleStartIndex = Math.max(0, startIndex - this.buffer)
const visibleEndIndex = Math.min(this.items.length, endIndex + this.buffer)
return {
items: this.items.slice(visibleStartIndex, visibleEndIndex),
startIndex: visibleStartIndex,
endIndex: visibleEndIndex,
totalHeight: this.items.length * this.itemHeight,
offsetY: visibleStartIndex * this.itemHeight
}
}
}
/**
* Image lazy loading
*/
export function setupImageLazyLoading() {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
img.classList.add('loaded')
imageObserver.unobserve(img)
}
})
})
const lazyImages = document.querySelectorAll('img[data-src]')
lazyImages.forEach(img => imageObserver.observe(img))
}
}
/**
* Request idle callback polyfill
*/
export const requestIdleCallback = window.requestIdleCallback || function(cb) {
const start = Date.now()
return setTimeout(() => {
cb({
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
})
}, 1)
}
/**
* Cancel idle callback polyfill
*/
export const cancelIdleCallback = window.cancelIdleCallback || function(id) {
clearTimeout(id)
}
/**
* Batch DOM updates
*/
export class DOMBatcher {
constructor() {
this.reads = []
this.writes = []
this.scheduled = false
}
read(fn) {
this.reads.push(fn)
this.scheduleFlush()
}
write(fn) {
this.writes.push(fn)
this.scheduleFlush()
}
scheduleFlush() {
if (!this.scheduled) {
this.scheduled = true
requestAnimationFrame(() => this.flush())
}
}
flush() {
const reads = this.reads.slice()
const writes = this.writes.slice()
this.reads.length = 0
this.writes.length = 0
this.scheduled = false
// Execute reads first
reads.forEach(fn => fn())
// Then execute writes
writes.forEach(fn => fn())
}
}
/**
* Memory leak detector
*/
export class MemoryLeakDetector {
constructor() {
this.observers = new WeakMap()
this.checkInterval = null
}
observe(object, name) {
this.observers.set(object, {
name,
timestamp: Date.now(),
size: JSON.stringify(object).length
})
}
startChecking(interval = 60000) {
this.checkInterval = setInterval(() => {
if (performance.memory) {
const memoryInfo = {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
}
const usage = (memoryInfo.usedJSHeapSize / memoryInfo.jsHeapSizeLimit) * 100
if (usage > 90) {
console.warn('High memory usage detected:', usage.toFixed(2) + '%')
}
}
}, interval)
}
stopChecking() {
if (this.checkInterval) {
clearInterval(this.checkInterval)
this.checkInterval = null
}
}
}
// Export singleton instances
export const performanceMonitor = new PerformanceMonitor()
export const domBatcher = new DOMBatcher()
export const memoryLeakDetector = new MemoryLeakDetector()

View File

@@ -0,0 +1,279 @@
// Service Worker registration and management
/**
* Register service worker
*/
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
console.log('Service Worker not supported')
return
}
try {
const registration = await navigator.serviceWorker.register('/service-worker.js', {
scope: '/'
})
console.log('Service Worker registered:', registration)
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
showUpdateNotification(newWorker)
}
})
})
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Every hour
return registration
} catch (error) {
console.error('Service Worker registration failed:', error)
}
}
/**
* Show update notification
*/
function showUpdateNotification(worker) {
const notification = document.createElement('div')
notification.className = 'sw-update-notification'
notification.innerHTML = `
<div class="sw-update-content">
<p>A new version is available!</p>
<button id="sw-update-btn">Update</button>
<button id="sw-dismiss-btn">Dismiss</button>
</div>
`
document.body.appendChild(notification)
document.getElementById('sw-update-btn').addEventListener('click', () => {
worker.postMessage({ type: 'SKIP_WAITING' })
window.location.reload()
})
document.getElementById('sw-dismiss-btn').addEventListener('click', () => {
notification.remove()
})
}
/**
* Request notification permission
*/
export async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('Notifications not supported')
return false
}
if (Notification.permission === 'granted') {
return true
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission()
return permission === 'granted'
}
return false
}
/**
* Subscribe to push notifications
*/
export async function subscribeToPushNotifications(registration) {
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.VITE_VAPID_PUBLIC_KEY)
})
// Send subscription to server
await fetch('/api/v1/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
return subscription
} catch (error) {
console.error('Failed to subscribe to push notifications:', error)
throw error
}
}
/**
* Unsubscribe from push notifications
*/
export async function unsubscribeFromPushNotifications() {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
// Notify server
await fetch('/api/v1/notifications/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ endpoint: subscription.endpoint })
})
}
} catch (error) {
console.error('Failed to unsubscribe from push notifications:', error)
throw error
}
}
/**
* Clear all caches
*/
export async function clearAllCaches() {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_CACHE' })
}
// Also clear caches directly
if ('caches' in window) {
const cacheNames = await caches.keys()
await Promise.all(cacheNames.map(name => caches.delete(name)))
}
}
/**
* Check if app is offline
*/
export function isOffline() {
return !navigator.onLine
}
/**
* Add offline/online event listeners
*/
export function setupNetworkListeners(callbacks = {}) {
const { onOnline, onOffline } = callbacks
window.addEventListener('online', () => {
console.log('App is online')
if (onOnline) onOnline()
})
window.addEventListener('offline', () => {
console.log('App is offline')
if (onOffline) onOffline()
})
}
/**
* Background sync
*/
export async function registerBackgroundSync(tag) {
const registration = await navigator.serviceWorker.ready
if ('sync' in registration) {
try {
await registration.sync.register(tag)
console.log(`Background sync registered: ${tag}`)
} catch (error) {
console.error('Background sync registration failed:', error)
}
}
}
/**
* Utility function to convert VAPID key
*/
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
// Add CSS for update notification
const style = document.createElement('style')
style.textContent = `
.sw-update-notification {
position: fixed;
bottom: 20px;
right: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
padding: 16px;
z-index: 9999;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.sw-update-content {
display: flex;
align-items: center;
gap: 12px;
}
.sw-update-content p {
margin: 0;
font-weight: 500;
}
.sw-update-content button {
padding: 6px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
#sw-update-btn {
background: #409eff;
color: white;
}
#sw-update-btn:hover {
background: #66b1ff;
}
#sw-dismiss-btn {
background: #f0f0f0;
color: #333;
}
#sw-dismiss-btn:hover {
background: #e0e0e0;
}
`
document.head.appendChild(style)

View File

@@ -0,0 +1,201 @@
<template>
<div class="abtesting-page">
<el-card class="page-header">
<h1>{{ $t('abtesting.title') }}</h1>
<p>{{ $t('abtesting.subtitle') }}</p>
</el-card>
<el-row :gutter="20">
<el-col :span="24">
<el-card>
<template #header>
<div class="card-header">
<span>{{ $t('abtesting.experiments') }}</span>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
{{ $t('abtesting.createExperiment') }}
</el-button>
</div>
</template>
<el-table :data="experiments" style="width: 100%" v-loading="loading">
<el-table-column prop="name" :label="$t('abtesting.name')" />
<el-table-column prop="status" :label="$t('abtesting.status')">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="variants" :label="$t('abtesting.variants')">
<template #default="{ row }">
{{ row.variants.length }}
</template>
</el-table-column>
<el-table-column :label="$t('abtesting.progress')">
<template #default="{ row }">
<el-progress :percentage="row.progress" />
</template>
</el-table-column>
<el-table-column :label="$t('abtesting.actions')" width="200">
<template #default="{ row }">
<el-button size="small" @click="viewDetails(row)">
{{ $t('common.view') }}
</el-button>
<el-button
size="small"
type="success"
v-if="row.status === 'completed'"
@click="selectWinner(row)"
>
{{ $t('abtesting.selectWinner') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<!-- Create Experiment Dialog -->
<el-dialog
v-model="showCreateDialog"
:title="$t('abtesting.createExperiment')"
width="600px"
>
<el-form :model="newExperiment" label-width="120px">
<el-form-item :label="$t('abtesting.name')">
<el-input v-model="newExperiment.name" />
</el-form-item>
<el-form-item :label="$t('abtesting.description')">
<el-input type="textarea" v-model="newExperiment.description" />
</el-form-item>
<el-form-item :label="$t('abtesting.variants')">
<div v-for="(variant, index) in newExperiment.variants" :key="index" class="variant-item">
<el-input v-model="variant.name" placeholder="Variant name" />
<el-input v-model="variant.content" type="textarea" placeholder="Content" />
<el-button @click="removeVariant(index)" type="danger" :icon="Delete" />
</div>
<el-button @click="addVariant" type="primary">
{{ $t('abtesting.addVariant') }}
</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="createExperiment">
{{ $t('common.create') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus, Delete } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import api from '@/api'
const loading = ref(false)
const experiments = ref([])
const showCreateDialog = ref(false)
const newExperiment = ref({
name: '',
description: '',
variants: [
{ name: 'Control', content: '' },
{ name: 'Variant A', content: '' }
]
})
const loadExperiments = async () => {
loading.value = true
try {
const response = await api.abTesting.getExperiments()
experiments.value = response.data
} catch (error) {
ElMessage.error('Failed to load experiments')
} finally {
loading.value = false
}
}
const createExperiment = async () => {
try {
await api.abTesting.createExperiment(newExperiment.value)
ElMessage.success('Experiment created successfully')
showCreateDialog.value = false
loadExperiments()
} catch (error) {
ElMessage.error('Failed to create experiment')
}
}
const addVariant = () => {
newExperiment.value.variants.push({
name: `Variant ${String.fromCharCode(65 + newExperiment.value.variants.length - 1)}`,
content: ''
})
}
const removeVariant = (index) => {
if (newExperiment.value.variants.length > 2) {
newExperiment.value.variants.splice(index, 1)
}
}
const viewDetails = (experiment) => {
// Navigate to experiment details
}
const selectWinner = async (experiment) => {
// Select winner implementation
}
const getStatusType = (status) => {
const types = {
draft: 'info',
running: 'primary',
completed: 'success',
cancelled: 'danger'
}
return types[status] || 'info'
}
onMounted(() => {
loadExperiments()
})
</script>
<style lang="scss" scoped>
.abtesting-page {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
h1 {
margin: 0 0 10px 0;
color: var(--el-text-color-primary);
}
p {
margin: 0;
color: var(--el-text-color-regular);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.variant-item {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="accounts-page">
<el-card class="page-header">
<h1>{{ $t('accounts.title') }}</h1>
<p>{{ $t('accounts.subtitle') }}</p>
</el-card>
<el-row :gutter="20">
<el-col :span="24">
<el-card>
<el-table :data="accounts" style="width: 100%" v-loading="loading">
<el-table-column prop="phoneNumber" :label="$t('accounts.phoneNumber')" />
<el-table-column prop="status" :label="$t('accounts.status')">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastUsed" :label="$t('accounts.lastUsed')" />
<el-table-column prop="messagesSent" :label="$t('accounts.messagesSent')" />
<el-table-column :label="$t('accounts.actions')" width="200">
<template #default="{ row }">
<el-button size="small" @click="viewDetails(row)">
{{ $t('common.view') }}
</el-button>
<el-button
size="small"
:type="row.status === 'active' ? 'danger' : 'success'"
@click="toggleStatus(row)"
>
{{ row.status === 'active' ? $t('common.disable') : $t('common.enable') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api'
const loading = ref(false)
const accounts = ref([])
const loadAccounts = async () => {
loading.value = true
try {
const response = await api.accounts.getList()
accounts.value = response.data
} catch (error) {
ElMessage.error('Failed to load accounts')
} finally {
loading.value = false
}
}
const getStatusType = (status) => {
const types = {
active: 'success',
inactive: 'info',
banned: 'danger',
restricted: 'warning'
}
return types[status] || 'info'
}
const viewDetails = (account) => {
// Navigate to account details
}
const toggleStatus = async (account) => {
try {
const newStatus = account.status === 'active' ? 'inactive' : 'active'
await api.accounts.updateStatus(account.id, newStatus)
ElMessage.success('Account status updated')
loadAccounts()
} catch (error) {
ElMessage.error('Failed to update account status')
}
}
onMounted(() => {
loadAccounts()
})
</script>
<style lang="scss" scoped>
.accounts-page {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
h1 {
margin: 0 0 10px 0;
color: var(--el-text-color-primary);
}
p {
margin: 0;
color: var(--el-text-color-regular);
}
}
</style>

View File

@@ -0,0 +1,348 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Telegram Accounts</h1>
<el-button type="primary" @click="showConnectDialog = true">
<el-icon class="mr-2"><Plus /></el-icon>
Connect Account
</el-button>
</div>
<!-- Accounts List -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<el-card v-for="account in accounts" :key="account.id" shadow="hover">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center mb-2">
<el-tag :type="account.connected ? 'success' : 'info'" size="small">
{{ account.connected ? 'Connected' : 'Inactive' }}
</el-tag>
<el-tag v-if="account.isDemo" type="warning" size="small" class="ml-2">
Demo
</el-tag>
</div>
<h3 class="font-semibold text-lg">{{ account.username || 'Unknown' }}</h3>
<p class="text-gray-500">{{ account.phone }}</p>
<div class="mt-2 text-sm text-gray-600">
<p>Messages sent: {{ account.messageCount || 0 }}</p>
<p>Last active: {{ formatTime(account.lastActive) }}</p>
</div>
</div>
<el-dropdown @command="(cmd) => handleCommand(cmd, account)">
<el-button circle size="small">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="!account.connected" command="reconnect">
Reconnect
</el-dropdown-item>
<el-dropdown-item command="status">View Status</el-dropdown-item>
<el-dropdown-item command="disconnect" divided>
<span class="text-red-500">Disconnect</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-card>
<!-- Empty State -->
<div v-if="accounts.length === 0" class="col-span-full text-center py-12">
<el-empty description="No accounts connected">
<el-button type="primary" @click="showConnectDialog = true">
Connect First Account
</el-button>
</el-empty>
</div>
</div>
<!-- Connect Account Dialog -->
<el-dialog
v-model="showConnectDialog"
title="Connect Telegram Account"
width="500px"
@close="resetConnectForm"
>
<div v-if="!authStep">
<el-form ref="connectFormRef" :model="connectForm" :rules="connectRules" label-position="top">
<el-form-item label="Phone Number" prop="phone">
<el-input
v-model="connectForm.phone"
placeholder="+1234567890"
prefix-icon="Phone"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="connectForm.demo">
Create demo account (for testing)
</el-checkbox>
</el-form-item>
</el-form>
</div>
<div v-else-if="authStep === 'code'">
<el-alert type="info" :closable="false" class="mb-4">
Please check your Telegram app for the authentication code
</el-alert>
<el-form ref="verifyFormRef" :model="verifyForm" label-position="top">
<el-form-item label="Verification Code" prop="code">
<el-input
v-model="verifyForm.code"
placeholder="12345"
maxlength="5"
/>
</el-form-item>
<el-form-item v-if="requiresPassword" label="2FA Password" prop="password">
<el-input
v-model="verifyForm.password"
type="password"
placeholder="Your 2FA password"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showConnectDialog = false">Cancel</el-button>
<el-button
type="primary"
:loading="connecting"
@click="authStep ? verifyCode() : connectAccount()"
>
{{ authStep ? 'Verify' : 'Connect' }}
</el-button>
</span>
</template>
</el-dialog>
<!-- Status Dialog -->
<el-dialog
v-model="showStatusDialog"
title="Account Status"
width="500px"
>
<el-descriptions v-if="selectedAccount" :column="1" border>
<el-descriptions-item label="Account ID">
{{ selectedAccount.accountId }}
</el-descriptions-item>
<el-descriptions-item label="Phone">
{{ selectedAccount.phone }}
</el-descriptions-item>
<el-descriptions-item label="Username">
{{ selectedAccount.username || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="Status">
<el-tag :type="selectedAccount.connected ? 'success' : 'info'">
{{ selectedAccount.connected ? 'Connected' : 'Inactive' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Messages Sent">
{{ selectedAccount.messageCount || 0 }}
</el-descriptions-item>
<el-descriptions-item label="Last Active">
{{ formatTime(selectedAccount.lastActive) }}
</el-descriptions-item>
<el-descriptions-item label="Uptime">
{{ formatUptime(selectedAccount.uptime) }}
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Phone, MoreFilled } from '@element-plus/icons-vue'
import api from '@/api'
const accounts = ref([])
const showConnectDialog = ref(false)
const showStatusDialog = ref(false)
const selectedAccount = ref(null)
const connecting = ref(false)
const authStep = ref('')
const pendingAccountId = ref('')
const requiresPassword = ref(false)
const connectForm = reactive({
phone: '',
demo: false
})
const verifyForm = reactive({
code: '',
password: ''
})
const connectRules = {
phone: [
{ required: true, message: 'Please enter phone number', trigger: 'blur' },
{ pattern: /^\+?[1-9]\d{1,14}$/, message: 'Invalid phone number format', trigger: 'blur' }
]
}
const connectFormRef = ref()
const verifyFormRef = ref()
onMounted(() => {
loadAccounts()
})
const loadAccounts = async () => {
try {
const response = await api.accounts.getAccounts()
accounts.value = response.data.data || []
} catch (error) {
console.error('Failed to load accounts:', error)
ElMessage.error('Failed to load accounts')
}
}
const connectAccount = async () => {
const valid = await connectFormRef.value.validate()
if (!valid) return
connecting.value = true
try {
const response = await api.accounts.connectAccount({
phone: connectForm.phone,
demo: connectForm.demo
})
if (response.data.success) {
if (response.data.data.demo) {
ElMessage.success('Demo account created successfully')
showConnectDialog.value = false
loadAccounts()
} else {
// Need authentication
pendingAccountId.value = response.data.data.accountId
authStep.value = 'code'
ElMessage.info(response.data.data.message)
}
}
} catch (error) {
console.error('Failed to connect account:', error)
ElMessage.error(error.response?.data?.error || 'Failed to connect account')
} finally {
connecting.value = false
}
}
const verifyCode = async () => {
if (!verifyForm.code) {
ElMessage.warning('Please enter verification code')
return
}
connecting.value = true
try {
const response = await api.accounts.verifyAccount(pendingAccountId.value, {
code: verifyForm.code,
password: verifyForm.password
})
if (response.data.success) {
ElMessage.success('Account connected successfully')
showConnectDialog.value = false
loadAccounts()
}
} catch (error) {
console.error('Verification failed:', error)
if (error.response?.data?.requiresPassword) {
requiresPassword.value = true
ElMessage.warning('2FA password required')
} else {
ElMessage.error(error.response?.data?.error || 'Verification failed')
}
} finally {
connecting.value = false
}
}
const handleCommand = async (command, account) => {
switch (command) {
case 'status':
try {
const response = await api.accounts.getAccountStatus(account.id)
selectedAccount.value = response.data.data
showStatusDialog.value = true
} catch (error) {
ElMessage.error('Failed to get account status')
}
break
case 'reconnect':
try {
await ElMessageBox.confirm(
'Reconnect this account?',
'Confirm',
{ type: 'info' }
)
const response = await api.accounts.reconnectAccount(account.id)
if (response.data.success) {
ElMessage.success('Account reconnected successfully')
loadAccounts()
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to reconnect account')
}
}
break
case 'disconnect':
try {
await ElMessageBox.confirm(
'Are you sure you want to disconnect this account?',
'Warning',
{ type: 'warning' }
)
await api.accounts.disconnectAccount(account.id)
ElMessage.success('Account disconnected')
loadAccounts()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to disconnect account')
}
}
break
}
}
const resetConnectForm = () => {
connectForm.phone = ''
connectForm.demo = false
verifyForm.code = ''
verifyForm.password = ''
authStep.value = ''
pendingAccountId.value = ''
requiresPassword.value = false
}
const formatTime = (timestamp) => {
if (!timestamp) return 'Never'
const date = new Date(timestamp)
return date.toLocaleString()
}
const formatUptime = (uptime) => {
if (!uptime) return 'N/A'
const hours = Math.floor(uptime / 3600000)
const minutes = Math.floor((uptime % 3600000) / 60000)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
}
</script>

View File

@@ -0,0 +1,416 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900">{{ t('analytics.overview') }}</h1>
<div class="flex items-center space-x-3">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="to"
start-placeholder="Start date"
end-placeholder="End date"
@change="loadAnalytics"
/>
<el-button icon="Download" @click="exportReport">
Export Report
</el-button>
</div>
</div>
<!-- Key Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<el-card v-for="metric in keyMetrics" :key="metric.title">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">{{ metric.title }}</p>
<p class="mt-2 text-2xl font-semibold">{{ metric.value }}</p>
<div class="mt-1 flex items-center text-sm">
<el-icon :class="metric.trend > 0 ? 'text-green-500' : 'text-red-500'">
<component :is="metric.trend > 0 ? 'TrendCharts' : 'TrendCharts'" />
</el-icon>
<span :class="metric.trend > 0 ? 'text-green-500' : 'text-red-500'">
{{ Math.abs(metric.trend) }}%
</span>
<span class="text-gray-500 ml-1">vs last period</span>
</div>
</div>
<div :class="`p-3 rounded-full ${metric.iconBg}`">
<component :is="metric.icon" class="h-6 w-6" :class="metric.iconColor" />
</div>
</div>
</el-card>
</div>
<!-- Charts Row 1 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Messages Over Time -->
<el-card>
<template #header>
<span>Messages Over Time</span>
</template>
<div class="chart-container" style="height: 350px">
<Line :data="messagesChartData" :options="chartOptions" />
</div>
</el-card>
<!-- Engagement Rate -->
<el-card>
<template #header>
<span>Engagement Rate by Day</span>
</template>
<div class="chart-container" style="height: 350px">
<Bar :data="engagementChartData" :options="barChartOptions" />
</div>
</el-card>
</div>
<!-- Charts Row 2 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Campaign Performance -->
<el-card class="lg:col-span-2">
<template #header>
<span>Campaign Performance Comparison</span>
</template>
<el-table :data="campaignPerformance" style="width: 100%">
<el-table-column prop="name" label="Campaign" min-width="200" />
<el-table-column prop="messages" label="Messages" width="100" />
<el-table-column prop="delivered" label="Delivered" width="100">
<template #default="{ row }">
{{ row.delivered }} ({{ row.deliveryRate }}%)
</template>
</el-table-column>
<el-table-column prop="opened" label="Opened" width="100">
<template #default="{ row }">
{{ row.opened }} ({{ row.openRate }}%)
</template>
</el-table-column>
<el-table-column prop="clicked" label="Clicked" width="100">
<template #default="{ row }">
{{ row.clicked }} ({{ row.ctr }}%)
</template>
</el-table-column>
<el-table-column prop="conversions" label="Conversions" width="120">
<template #default="{ row }">
{{ row.conversions }} ({{ row.conversionRate }}%)
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Top Performing Content -->
<el-card>
<template #header>
<span>Top Performing Content</span>
</template>
<div class="space-y-3">
<div
v-for="(content, index) in topContent"
:key="index"
class="p-3 bg-gray-50 rounded-lg"
>
<p class="text-sm font-medium text-gray-900 truncate">{{ content.preview }}</p>
<div class="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{{ content.engagement }}% engagement</span>
<span>{{ content.clicks }} clicks</span>
</div>
</div>
</div>
</el-card>
</div>
<!-- Real-time Activity -->
<el-card>
<template #header>
<div class="flex items-center justify-between">
<span>Real-time Activity</span>
<el-badge :value="realtimeActivity.length" class="item" type="primary">
<el-button size="small" :icon="realtimeEnabled ? 'VideoPause' : 'VideoPlay'" @click="toggleRealtime">
{{ realtimeEnabled ? 'Pause' : 'Resume' }}
</el-button>
</el-badge>
</div>
</template>
<el-timeline>
<el-timeline-item
v-for="activity in realtimeActivity"
:key="activity.id"
:timestamp="activity.timestamp"
:type="activity.type"
>
{{ activity.description }}
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Line, Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import {
Message,
TrendCharts,
SuccessFilled,
User
} from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import api from '@/api'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend
)
const { t } = useI18n()
const dateRange = ref([
dayjs().subtract(7, 'days').toDate(),
dayjs().toDate()
])
const realtimeEnabled = ref(true)
const realtimeActivity = ref([])
let realtimeInterval = null
const keyMetrics = ref([
{
title: 'Total Messages',
value: '156,234',
trend: 12.5,
icon: Message,
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600'
},
{
title: 'Engagement Rate',
value: '68.4%',
trend: 5.2,
icon: TrendCharts,
iconBg: 'bg-green-100',
iconColor: 'text-green-600'
},
{
title: 'Conversion Rate',
value: '12.8%',
trend: -2.1,
icon: SuccessFilled,
iconBg: 'bg-purple-100',
iconColor: 'text-purple-600'
},
{
title: 'Active Users',
value: '8,432',
trend: 8.7,
icon: User,
iconBg: 'bg-orange-100',
iconColor: 'text-orange-600'
}
])
const messagesChartData = reactive({
labels: [],
datasets: [
{
label: 'Messages Sent',
data: [],
borderColor: '#06b6d4',
backgroundColor: 'rgba(6, 182, 212, 0.1)',
tension: 0.4
},
{
label: 'Messages Delivered',
data: [],
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4
}
]
})
const engagementChartData = reactive({
labels: [],
datasets: [
{
label: 'Open Rate',
data: [],
backgroundColor: '#8b5cf6'
},
{
label: 'Click Rate',
data: [],
backgroundColor: '#f59e0b'
}
]
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
},
scales: {
y: {
beginAtZero: true
}
}
}
const barChartOptions = {
...chartOptions,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value + '%'
}
}
}
}
}
const campaignPerformance = ref([])
const topContent = ref([
{
preview: '🎉 Special offer just for you! Get 20% off...',
engagement: 78,
clicks: 1234
},
{
preview: 'Don\'t miss out on our exclusive deals...',
engagement: 65,
clicks: 987
},
{
preview: 'New products are here! Check them out...',
engagement: 62,
clicks: 856
},
{
preview: 'Last chance to save big on your favorites...',
engagement: 58,
clicks: 745
}
])
const loadAnalytics = async () => {
try {
const params = {
startDate: dayjs(dateRange.value[0]).format('YYYY-MM-DD'),
endDate: dayjs(dateRange.value[1]).format('YYYY-MM-DD')
}
// Load message metrics
const metricsResponse = await api.analytics.getMessageMetrics(params)
updateChartData(metricsResponse.data)
// Load campaign performance
const campaignsResponse = await api.analytics.getCampaignMetrics(null, params)
campaignPerformance.value = campaignsResponse.data.campaigns
// Load engagement metrics
const engagementResponse = await api.analytics.getEngagementMetrics(params)
updateEngagementChart(engagementResponse.data)
} catch (error) {
console.error('Failed to load analytics:', error)
}
}
const updateChartData = (data) => {
messagesChartData.labels = data.timeline.map(item =>
dayjs(item.date).format('MM/DD')
)
messagesChartData.datasets[0].data = data.timeline.map(item => item.sent)
messagesChartData.datasets[1].data = data.timeline.map(item => item.delivered)
}
const updateEngagementChart = (data) => {
engagementChartData.labels = data.daily.map(item =>
dayjs(item.date).format('MM/DD')
)
engagementChartData.datasets[0].data = data.daily.map(item => item.openRate)
engagementChartData.datasets[1].data = data.daily.map(item => item.clickRate)
}
const loadRealtimeActivity = async () => {
if (!realtimeEnabled.value) return
try {
const response = await api.analytics.getRealTimeMetrics()
// Add new activities to the beginning
const newActivities = response.data.activities.map(activity => ({
id: Date.now() + Math.random(),
timestamp: dayjs(activity.timestamp).format('HH:mm:ss'),
description: activity.description,
type: activity.type || 'primary'
}))
realtimeActivity.value = [...newActivities, ...realtimeActivity.value].slice(0, 10)
} catch (error) {
console.error('Failed to load realtime activity:', error)
}
}
const toggleRealtime = () => {
realtimeEnabled.value = !realtimeEnabled.value
if (realtimeEnabled.value) {
loadRealtimeActivity()
}
}
const exportReport = async () => {
try {
const response = await api.analytics.generateReport({
type: 'comprehensive',
startDate: dayjs(dateRange.value[0]).format('YYYY-MM-DD'),
endDate: dayjs(dateRange.value[1]).format('YYYY-MM-DD'),
format: 'pdf'
})
ElMessage.success('Report generation started. You will be notified when it\'s ready.')
} catch (error) {
ElMessage.error('Failed to generate report')
}
}
onMounted(() => {
loadAnalytics()
loadRealtimeActivity()
// Set up realtime updates
realtimeInterval = setInterval(() => {
loadRealtimeActivity()
}, 5000)
})
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval)
}
})
</script>

View File

@@ -0,0 +1,184 @@
<template>
<div class="compliance-page">
<el-card class="page-header">
<h1>{{ $t('compliance.title') }}</h1>
<p>{{ $t('compliance.subtitle') }}</p>
</el-card>
<el-row :gutter="20">
<!-- Compliance Status -->
<el-col :span="12">
<el-card>
<template #header>
<span>{{ $t('compliance.status') }}</span>
</template>
<div class="compliance-status">
<div class="status-item">
<span>GDPR Compliance</span>
<el-tag type="success">Compliant</el-tag>
</div>
<div class="status-item">
<span>CCPA Compliance</span>
<el-tag type="success">Compliant</el-tag>
</div>
<div class="status-item">
<span>Data Retention Policy</span>
<el-tag type="success">Active</el-tag>
</div>
</div>
</el-card>
</el-col>
<!-- Data Requests -->
<el-col :span="12">
<el-card>
<template #header>
<span>{{ $t('compliance.dataRequests') }}</span>
</template>
<el-table :data="dataRequests" style="width: 100%">
<el-table-column prop="type" :label="$t('compliance.requestType')" />
<el-table-column prop="status" :label="$t('compliance.status')">
<template #default="{ row }">
<el-tag :type="getRequestStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" :label="$t('compliance.date')" />
</el-table>
</el-card>
</el-col>
</el-row>
<!-- Audit Log -->
<el-row :gutter="20" class="mt-4">
<el-col :span="24">
<el-card>
<template #header>
<div class="card-header">
<span>{{ $t('compliance.auditLog') }}</span>
<el-button @click="exportAuditLog">
{{ $t('compliance.exportLog') }}
</el-button>
</div>
</template>
<el-table :data="auditLogs" style="width: 100%" v-loading="loading">
<el-table-column prop="action" :label="$t('compliance.action')" />
<el-table-column prop="user" :label="$t('compliance.user')" />
<el-table-column prop="timestamp" :label="$t('compliance.timestamp')" />
<el-table-column prop="details" :label="$t('compliance.details')">
<template #default="{ row }">
<el-button size="small" @click="viewDetails(row)">
{{ $t('common.view') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api'
const loading = ref(false)
const dataRequests = ref([])
const auditLogs = ref([])
const loadDataRequests = async () => {
try {
const response = await api.compliance.getDataRequests()
dataRequests.value = response.data
} catch (error) {
ElMessage.error('Failed to load data requests')
}
}
const loadAuditLogs = async () => {
loading.value = true
try {
const response = await api.compliance.getAuditLogs()
auditLogs.value = response.data
} catch (error) {
ElMessage.error('Failed to load audit logs')
} finally {
loading.value = false
}
}
const getRequestStatusType = (status) => {
const types = {
pending: 'warning',
processing: 'primary',
completed: 'success',
failed: 'danger'
}
return types[status] || 'info'
}
const viewDetails = (log) => {
// Show log details
}
const exportAuditLog = async () => {
try {
await api.compliance.exportAuditLog()
ElMessage.success('Audit log exported successfully')
} catch (error) {
ElMessage.error('Failed to export audit log')
}
}
onMounted(() => {
loadDataRequests()
loadAuditLogs()
})
</script>
<style lang="scss" scoped>
.compliance-page {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
h1 {
margin: 0 0 10px 0;
color: var(--el-text-color-primary);
}
p {
margin: 0;
color: var(--el-text-color-regular);
}
}
.compliance-status {
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.mt-4 {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<!-- Mobile Dashboard -->
<DashboardMobile v-if="isMobile" />
<!-- Desktop Dashboard -->
<div v-else class="space-y-6">
<!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<el-card v-for="stat in stats" :key="stat.title" shadow="hover">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">{{ t(stat.title) }}</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ stat.value }}
</p>
<p class="mt-1 text-sm" :class="stat.changeClass">
{{ stat.change }}
</p>
</div>
<div class="p-3 rounded-full" :class="stat.iconBg">
<component :is="stat.icon" class="h-6 w-6" :class="stat.iconColor" />
</div>
</div>
</el-card>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Campaign Performance Chart -->
<el-card>
<template #header>
<div class="flex items-center justify-between">
<span>Campaign Performance</span>
<el-select v-model="timeRange" size="small" style="width: 120px">
<el-option label="Last 7 days" value="7d" />
<el-option label="Last 30 days" value="30d" />
<el-option label="Last 90 days" value="90d" />
</el-select>
</div>
</template>
<div class="chart-container" style="height: 300px">
<Line :data="campaignChartData" :options="chartOptions" />
</div>
</el-card>
<!-- Engagement Distribution -->
<el-card>
<template #header>
<span>Engagement Distribution</span>
</template>
<div class="chart-container" style="height: 300px">
<Doughnut :data="engagementChartData" :options="doughnutOptions" />
</div>
</el-card>
</div>
<!-- Recent Activity & Quick Actions -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Recent Activity -->
<el-card class="lg:col-span-2">
<template #header>
<span>{{ t('dashboard.recentActivity') }}</span>
</template>
<el-timeline>
<el-timeline-item
v-for="activity in recentActivities"
:key="activity.id"
:timestamp="activity.timestamp"
:type="activity.type"
>
{{ activity.content }}
</el-timeline-item>
</el-timeline>
</el-card>
<!-- Quick Actions -->
<el-card>
<template #header>
<span>{{ t('dashboard.quickActions') }}</span>
</template>
<div class="space-y-3">
<el-button
type="primary"
icon="Plus"
class="w-full"
@click="$router.push('/campaigns/create')"
>
{{ t('dashboard.createCampaign') }}
</el-button>
<el-button
icon="DataAnalysis"
class="w-full"
@click="$router.push('/analytics')"
>
{{ t('dashboard.viewAnalytics') }}
</el-button>
<el-button
icon="User"
class="w-full"
@click="$router.push('/accounts')"
>
{{ t('dashboard.manageAccounts') }}
</el-button>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Line, Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
ArcElement
} from 'chart.js'
import {
Management,
Message,
TrendCharts,
SuccessFilled
} from '@element-plus/icons-vue'
import api from '@/api'
import DashboardMobile from './DashboardMobile.vue'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
ArcElement
)
const { t } = useI18n()
const timeRange = ref('7d')
const isMobile = ref(false)
// Check if device is mobile
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
const stats = reactive([
{
title: 'dashboard.activeCampaigns',
value: '12',
change: '+2 from last week',
changeClass: 'text-green-600',
icon: Management,
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600'
},
{
title: 'dashboard.totalMessages',
value: '24,563',
change: '+15% from last week',
changeClass: 'text-green-600',
icon: Message,
iconBg: 'bg-green-100',
iconColor: 'text-green-600'
},
{
title: 'dashboard.engagementRate',
value: '68.4%',
change: '+5.2% from last week',
changeClass: 'text-green-600',
icon: TrendCharts,
iconBg: 'bg-purple-100',
iconColor: 'text-purple-600'
},
{
title: 'dashboard.conversionRate',
value: '12.8%',
change: '-2.1% from last week',
changeClass: 'text-red-600',
icon: SuccessFilled,
iconBg: 'bg-yellow-100',
iconColor: 'text-yellow-600'
}
])
const campaignChartData = reactive({
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
datasets: [
{
label: 'Messages Sent',
data: [3200, 4100, 3800, 4500, 4200, 5100, 4800],
borderColor: '#06b6d4',
backgroundColor: 'rgba(6, 182, 212, 0.1)',
tension: 0.4
},
{
label: 'Engagement',
data: [2100, 2800, 2600, 3200, 2900, 3500, 3300],
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.4
}
]
})
const engagementChartData = reactive({
labels: ['Opened', 'Clicked', 'Converted', 'Ignored'],
datasets: [
{
data: [45, 23, 12, 20],
backgroundColor: ['#06b6d4', '#8b5cf6', '#10b981', '#ef4444'],
borderWidth: 0
}
]
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
},
scales: {
y: {
beginAtZero: true
}
}
}
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right'
}
}
}
const recentActivities = ref([
{
id: 1,
content: 'Campaign "Summer Sale" completed successfully',
timestamp: '2024-01-20 14:30',
type: 'success'
},
{
id: 2,
content: 'New A/B test "Welcome Message" started',
timestamp: '2024-01-20 13:15',
type: 'primary'
},
{
id: 3,
content: '500 new contacts imported',
timestamp: '2024-01-20 11:45',
type: 'info'
},
{
id: 4,
content: 'Campaign "Flash Sale" paused due to rate limit',
timestamp: '2024-01-20 10:20',
type: 'warning'
}
])
onMounted(async () => {
try {
// Load dashboard metrics
const response = await api.analytics.getDashboardMetrics({
timeRange: timeRange.value
})
// Update stats and charts with real data
} catch (error) {
console.error('Failed to load dashboard metrics:', error)
}
})
</script>

View File

@@ -0,0 +1,210 @@
<template>
<div class="space-y-6">
<!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<el-card v-for="stat in stats" :key="stat.title" shadow="hover">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">{{ stat.title }}</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ stat.value }}
</p>
<p class="mt-1 text-sm" :class="stat.change > 0 ? 'text-green-600' : 'text-red-600'">
{{ stat.change > 0 ? '+' : '' }}{{ stat.change }}%
</p>
</div>
<div class="p-3 rounded-full bg-blue-100">
<el-icon class="text-2xl text-blue-600">
<DataAnalysis />
</el-icon>
</div>
</div>
</el-card>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Campaign Performance Chart -->
<el-card>
<template #header>
<div class="flex items-center justify-between">
<span>Campaign Performance</span>
<el-select v-model="timeRange" size="small" style="width: 120px">
<el-option label="Last 7 days" value="7d" />
<el-option label="Last 30 days" value="30d" />
</el-select>
</div>
</template>
<div v-if="chartsSupported && campaignChartData" class="chart-container" style="height: 300px">
<LineChart :data="campaignChartData" :options="chartOptions" />
</div>
<div v-else class="h-[300px] flex items-center justify-center text-gray-500">
<p>Chart visualization not available</p>
</div>
</el-card>
<!-- Recent Activity -->
<el-card>
<template #header>
<span>Recent Activity</span>
</template>
<el-timeline>
<el-timeline-item
v-for="activity in recentActivities"
:key="activity.id"
:timestamp="formatTime(activity.timestamp)"
:type="getActivityType(activity.type)"
>
{{ activity.content || activity.campaign }}
</el-timeline-item>
</el-timeline>
</el-card>
</div>
<!-- Quick Actions -->
<el-card>
<template #header>
<span>Quick Actions</span>
</template>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<el-button type="primary" @click="$router.push('/campaigns/create')">
<el-icon class="mr-2"><Plus /></el-icon>
New Campaign
</el-button>
<el-button @click="$router.push('/analytics')">
<el-icon class="mr-2"><DataAnalysis /></el-icon>
View Analytics
</el-button>
<el-button @click="$router.push('/accounts')">
<el-icon class="mr-2"><User /></el-icon>
Manage Accounts
</el-button>
<el-button @click="$router.push('/settings')">
<el-icon class="mr-2"><Setting /></el-icon>
Settings
</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, shallowRef } from 'vue'
import { DataAnalysis, Plus, User, Setting } from '@element-plus/icons-vue'
import api from '@/api'
// Chart components - lazy loaded
let LineChart = null
let chartsSupported = ref(false)
// Try to load chart components
onMounted(async () => {
try {
const chartModule = await import('vue-chartjs')
const chartjsModule = await import('chart.js')
// Register Chart.js components
const { Chart, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } = chartjsModule
Chart.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend)
// Set Line chart component
LineChart = chartModule.Line
chartsSupported.value = true
} catch (error) {
console.warn('Charts not available:', error)
chartsSupported.value = false
}
// Load dashboard data
loadDashboardData()
})
const timeRange = ref('7d')
const stats = ref([
{ title: 'Active Campaigns', value: 0, change: 0 },
{ title: 'Total Messages', value: 0, change: 0 },
{ title: 'Delivery Rate', value: '0%', change: 0 },
{ title: 'Click Rate', value: '0%', change: 0 }
])
const campaignChartData = ref(null)
const recentActivities = ref([])
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' }
}
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleString()
}
const getActivityType = (type) => {
const typeMap = {
'campaign_started': 'success',
'message_sent': 'primary',
'campaign_completed': 'info'
}
return typeMap[type] || 'info'
}
const loadDashboardData = async () => {
try {
const response = await api.analytics.getDashboardMetrics({ timeRange: timeRange.value })
if (response.data?.data) {
const { overview, recentActivity, performance } = response.data.data
// Update stats
if (overview) {
stats.value = [
{ title: 'Active Campaigns', value: overview.activeCampaigns || 0, change: 2 },
{ title: 'Total Messages', value: overview.totalMessages || 0, change: 15 },
{ title: 'Delivery Rate', value: `${overview.deliveryRate || 0}%`, change: 0.3 },
{ title: 'Click Rate', value: `${overview.clickRate || 0}%`, change: -1.2 }
]
}
// Update activities
recentActivities.value = recentActivity || []
// Update chart data if charts are supported
if (chartsSupported.value && performance?.daily) {
campaignChartData.value = {
labels: performance.daily.map(d => d.date.split('-').pop()),
datasets: [
{
label: 'Messages Sent',
data: performance.daily.map(d => d.sent),
borderColor: '#06b6d4',
backgroundColor: 'rgba(6, 182, 212, 0.1)'
},
{
label: 'Messages Delivered',
data: performance.daily.map(d => d.delivered),
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)'
}
]
}
}
}
} catch (error) {
console.error('Failed to load dashboard data:', error)
}
}
</script>
<style scoped>
.chart-container {
position: relative;
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,261 @@
<template>
<div class="p-4 pb-20">
<!-- Quick Stats -->
<div class="grid grid-cols-2 gap-3 mb-6">
<el-card
v-for="stat in stats"
:key="stat.title"
:body-style="{ padding: '12px' }"
class="text-center"
>
<div class="text-2xl font-bold" :style="{ color: stat.color }">
{{ stat.value }}
</div>
<div class="text-xs text-gray-600 mt-1">{{ stat.title }}</div>
</el-card>
</div>
<!-- Campaign Performance Chart -->
<el-card class="mb-4" :body-style="{ padding: '12px' }">
<template #header>
<div class="flex items-center justify-between">
<span class="text-sm font-medium">{{ t('dashboard.campaignPerformance') }}</span>
<el-button text size="small" @click="refreshData">
<el-icon><Refresh /></el-icon>
</el-button>
</div>
</template>
<div class="h-48">
<LineChart :data="campaignData" :options="mobileChartOptions" />
</div>
</el-card>
<!-- Recent Campaigns -->
<el-card :body-style="{ padding: '12px' }">
<template #header>
<div class="flex items-center justify-between">
<span class="text-sm font-medium">{{ t('dashboard.recentCampaigns') }}</span>
<router-link to="/campaigns" class="text-blue-600 text-xs">
{{ t('common.viewAll') }}
</router-link>
</div>
</template>
<div class="space-y-3">
<div
v-for="campaign in recentCampaigns"
:key="campaign.id"
class="border-b border-gray-100 pb-3 last:border-0"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900 truncate">
{{ campaign.name }}
</h4>
<p class="text-xs text-gray-500 mt-1">
{{ formatDate(campaign.createdAt) }}
</p>
</div>
<el-tag
:type="getStatusType(campaign.status)"
size="small"
effect="plain"
>
{{ campaign.status }}
</el-tag>
</div>
<div class="flex items-center justify-between mt-2 text-xs">
<span class="text-gray-600">
<el-icon class="align-middle"><User /></el-icon>
{{ campaign.recipients }}
</span>
<span class="text-gray-600">
<el-icon class="align-middle"><View /></el-icon>
{{ campaign.delivered }}
</span>
<span class="text-gray-600">
<el-icon class="align-middle"><ChatDotRound /></el-icon>
{{ campaign.engagement }}%
</span>
</div>
</div>
</div>
</el-card>
<!-- Quick Actions -->
<div class="fixed bottom-20 right-4 z-10">
<el-dropdown @command="handleQuickAction" placement="top">
<el-button type="primary" circle size="large">
<el-icon><Plus /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="campaign">
<el-icon><Management /></el-icon>
{{ t('dashboard.createCampaign') }}
</el-dropdown-item>
<el-dropdown-item command="template">
<el-icon><DocumentCopy /></el-icon>
{{ t('dashboard.createTemplate') }}
</el-dropdown-item>
<el-dropdown-item command="segment">
<el-icon><User /></el-icon>
{{ t('dashboard.createSegment') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import {
Refresh,
User,
View,
ChatDotRound,
Plus,
Management,
DocumentCopy
} from '@element-plus/icons-vue'
import LineChart from '@/components/charts/LineChart.vue'
import { formatDate } from '@/utils/date'
const { t } = useI18n()
const router = useRouter()
// Stats data
const stats = ref([
{ title: t('dashboard.totalCampaigns'), value: '156', color: '#3b82f6' },
{ title: t('dashboard.activeUsers'), value: '2.4K', color: '#10b981' },
{ title: t('dashboard.messagesSent'), value: '45.2K', color: '#f59e0b' },
{ title: t('dashboard.avgEngagement'), value: '68%', color: '#8b5cf6' }
])
// Campaign performance data
const campaignData = ref({
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
datasets: [
{
label: 'Messages Sent',
data: [1200, 1900, 1500, 2100, 2400, 2200, 2800],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
}
]
})
// Mobile-optimized chart options
const mobileChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
font: {
size: 10
}
}
},
y: {
grid: {
borderDash: [3, 3]
},
ticks: {
font: {
size: 10
}
}
}
}
}
// Recent campaigns
const recentCampaigns = ref([
{
id: 1,
name: 'Summer Sale Promotion',
status: 'active',
createdAt: new Date('2024-01-15'),
recipients: 1500,
delivered: 1423,
engagement: 72
},
{
id: 2,
name: 'New Product Launch',
status: 'scheduled',
createdAt: new Date('2024-01-14'),
recipients: 2000,
delivered: 0,
engagement: 0
},
{
id: 3,
name: 'Customer Survey',
status: 'completed',
createdAt: new Date('2024-01-13'),
recipients: 500,
delivered: 487,
engagement: 45
}
])
const getStatusType = (status) => {
const types = {
active: 'success',
scheduled: 'info',
completed: 'info',
paused: 'warning',
failed: 'danger'
}
return types[status] || 'info'
}
const refreshData = () => {
ElMessage.success(t('common.refreshSuccess'))
}
const handleQuickAction = (command) => {
switch (command) {
case 'campaign':
router.push('/campaigns/create')
break
case 'template':
router.push('/templates/create')
break
case 'segment':
router.push('/users/segments/create')
break
}
}
onMounted(() => {
// Load dashboard data
})
</script>
<style scoped>
.el-card {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="p-6">
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
<!-- Simple Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-white p-4 rounded shadow">
<h3 class="text-sm text-gray-500">Active Campaigns</h3>
<p class="text-2xl font-bold">{{ stats.activeCampaigns }}</p>
</div>
<div class="bg-white p-4 rounded shadow">
<h3 class="text-sm text-gray-500">Total Messages</h3>
<p class="text-2xl font-bold">{{ stats.totalMessages }}</p>
</div>
<div class="bg-white p-4 rounded shadow">
<h3 class="text-sm text-gray-500">Delivery Rate</h3>
<p class="text-2xl font-bold">{{ stats.deliveryRate }}%</p>
</div>
<div class="bg-white p-4 rounded shadow">
<h3 class="text-sm text-gray-500">Click Rate</h3>
<p class="text-2xl font-bold">{{ stats.clickRate }}%</p>
</div>
</div>
<!-- Recent Activity -->
<div class="bg-white p-6 rounded shadow">
<h2 class="text-lg font-bold mb-4">Recent Activity</h2>
<div class="space-y-2">
<div v-for="activity in recentActivity" :key="activity.id" class="border-l-4 border-blue-500 pl-4 py-2">
<p class="font-medium">{{ activity.content }}</p>
<p class="text-sm text-gray-500">{{ activity.timestamp }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const stats = ref({
activeCampaigns: 0,
totalMessages: 0,
deliveryRate: 0,
clickRate: 0
})
const recentActivity = ref([])
onMounted(async () => {
try {
// Load dashboard data
const response = await api.analytics.getDashboardMetrics()
console.log('Dashboard data:', response.data)
if (response.data && response.data.data) {
const data = response.data.data
stats.value = {
activeCampaigns: data.overview?.activeCampaigns || 0,
totalMessages: data.overview?.totalMessages || 0,
deliveryRate: data.overview?.deliveryRate || 0,
clickRate: data.overview?.clickRate || 0
}
recentActivity.value = data.recentActivity || []
}
} catch (error) {
console.error('Failed to load dashboard:', error)
// Use default data
stats.value = {
activeCampaigns: 5,
totalMessages: 45678,
deliveryRate: 98.5,
clickRate: 12.3
}
recentActivity.value = [
{
id: 1,
content: 'Campaign "Summer Sale" started',
timestamp: new Date().toLocaleString()
}
]
}
})
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div style="padding: 20px;">
<h1>Welcome to Marketing Agent System</h1>
<p>If you can see this, login was successful!</p>
<p>User: {{ user }}</p>
<button @click="logout" style="margin-top: 20px; padding: 10px 20px; background: red; color: white; border: none; cursor: pointer;">
Logout
</button>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
const user = authStore.user?.username || 'Unknown'
const logout = () => {
authStore.logout()
router.push('/login')
}
</script>

View File

@@ -0,0 +1,185 @@
<template>
<!-- Mobile Layout -->
<LayoutMobile v-if="isMobile" />
<!-- Desktop Layout -->
<div v-else class="h-full flex">
<!-- Sidebar -->
<aside class="w-64 bg-gray-800 text-white flex flex-col">
<div class="p-4">
<h1 class="text-xl font-bold">Marketing Agent</h1>
</div>
<nav class="flex-1 px-2 py-4 space-y-1">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="flex items-center px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 transition-colors"
:class="{ 'bg-gray-900': $route.path === item.path }"
>
<component :is="item.icon" class="mr-3 h-5 w-5" />
{{ t(item.label) }}
</router-link>
</nav>
<div class="p-4 border-t border-gray-700">
<div class="flex items-center">
<el-avatar :size="32" class="mr-3">
{{ userInitial }}
</el-avatar>
<div class="flex-1">
<p class="text-sm font-medium">{{ authStore.user?.username }}</p>
<p class="text-xs text-gray-400">{{ authStore.user?.email }}</p>
</div>
<el-dropdown @command="handleUserCommand">
<el-icon class="cursor-pointer">
<MoreFilled />
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
{{ t('settings.profile') }}
</el-dropdown-item>
<el-dropdown-item command="settings">
{{ t('menu.settings') }}
</el-dropdown-item>
<el-dropdown-item divided command="logout">
{{ t('auth.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm px-6 py-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800">
{{ pageTitle }}
</h2>
<div class="flex items-center space-x-4">
<!-- Language Switcher -->
<el-dropdown @command="changeLanguage">
<span class="el-dropdown-link cursor-pointer">
<el-icon class="mr-1"><Translate /></el-icon>
{{ currentLanguage }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="en">English</el-dropdown-item>
<el-dropdown-item command="zh">中文</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- Notifications -->
<el-badge :value="notifications" :hidden="!notifications">
<el-button icon="Bell" circle />
</el-badge>
</div>
</div>
</header>
<!-- Page Content -->
<div class="flex-1 overflow-auto p-6">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import LayoutMobile from './LayoutMobile.vue'
import {
HomeFilled,
DataAnalysis,
Management,
DataBoard,
User,
Setting,
DocumentChecked,
MoreFilled,
Translate,
Bell
} from '@element-plus/icons-vue'
const { t, locale } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const notifications = ref(3)
const isMobile = ref(false)
// Check if device is mobile
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
const menuItems = [
{ path: '/', label: 'menu.dashboard', icon: HomeFilled },
{ path: '/campaigns', label: 'menu.campaigns', icon: Management },
{ path: '/analytics', label: 'menu.analytics', icon: DataAnalysis },
{ path: '/ab-testing', label: 'menu.abTesting', icon: DataBoard },
{ path: '/accounts', label: 'menu.accounts', icon: User },
{ path: '/compliance', label: 'menu.compliance', icon: DocumentChecked },
{ path: '/settings', label: 'menu.settings', icon: Setting }
]
const userInitial = computed(() => {
return authStore.user?.username?.charAt(0).toUpperCase() || 'U'
})
const currentLanguage = computed(() => {
return locale.value === 'zh' ? '中文' : 'English'
})
const pageTitle = computed(() => {
const item = menuItems.find(item => item.path === route.path)
return item ? t(item.label) : ''
})
const changeLanguage = (lang) => {
locale.value = lang
localStorage.setItem('locale', lang)
}
const handleUserCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/settings?tab=profile')
break
case 'settings':
router.push('/settings')
break
case 'logout':
await authStore.logout()
ElMessage.success(t('auth.logoutSuccess'))
break
}
}
</script>

View File

@@ -0,0 +1,251 @@
<template>
<div class="h-full flex flex-col">
<!-- Mobile Header -->
<header class="bg-gray-800 text-white">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center">
<!-- Menu Toggle -->
<button
@click="sidebarOpen = true"
class="p-2 rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-white"
>
<el-icon :size="20"><Menu /></el-icon>
</button>
<h1 class="ml-3 text-lg font-semibold">Marketing Agent</h1>
</div>
<div class="flex items-center space-x-2">
<!-- Notifications -->
<el-badge :value="notifications" :hidden="!notifications" :offset="[-5, 5]">
<button class="p-2 rounded-md hover:bg-gray-700">
<el-icon :size="20"><Bell /></el-icon>
</button>
</el-badge>
<!-- User Menu -->
<el-dropdown @command="handleUserCommand" placement="bottom-end">
<button class="p-2 rounded-md hover:bg-gray-700">
<el-avatar :size="28">{{ userInitial }}</el-avatar>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
{{ t('settings.profile') }}
</el-dropdown-item>
<el-dropdown-item command="settings">
{{ t('menu.settings') }}
</el-dropdown-item>
<el-dropdown-item command="language">
<el-icon><Translate /></el-icon>
{{ currentLanguage }}
</el-dropdown-item>
<el-dropdown-item divided command="logout">
{{ t('auth.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</header>
<!-- Page Content -->
<main class="flex-1 overflow-auto bg-gray-50">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<!-- Mobile Bottom Navigation -->
<nav class="bg-white border-t border-gray-200">
<div class="flex justify-around">
<router-link
v-for="item in bottomNavItems"
:key="item.path"
:to="item.path"
class="flex-1 flex flex-col items-center py-2 px-3 text-xs font-medium"
:class="[
$route.path === item.path
? 'text-blue-600'
: 'text-gray-600 hover:text-gray-900'
]"
>
<component
:is="item.icon"
:size="24"
class="mb-1"
/>
{{ t(item.label) }}
</router-link>
</div>
</nav>
<!-- Sidebar Overlay -->
<transition name="slide">
<div v-if="sidebarOpen" class="fixed inset-0 z-50 flex">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-gray-600 bg-opacity-75"
@click="sidebarOpen = false"
></div>
<!-- Sidebar -->
<aside class="relative flex-1 flex flex-col max-w-xs w-full bg-gray-800 text-white">
<div class="absolute top-0 right-0 -mr-12 pt-2">
<button
@click="sidebarOpen = false"
class="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-white"
>
<el-icon :size="20" class="text-white"><Close /></el-icon>
</button>
</div>
<div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
<div class="flex items-center flex-shrink-0 px-4">
<h1 class="text-xl font-bold">Marketing Agent</h1>
</div>
<nav class="mt-5 px-2 space-y-1">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
@click="sidebarOpen = false"
class="flex items-center px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 transition-colors"
:class="{ 'bg-gray-900': $route.path === item.path }"
>
<component :is="item.icon" class="mr-3 h-5 w-5" />
{{ t(item.label) }}
</router-link>
</nav>
</div>
<div class="flex-shrink-0 p-4 border-t border-gray-700">
<div class="flex items-center">
<el-avatar :size="40" class="mr-3">
{{ userInitial }}
</el-avatar>
<div>
<p class="text-sm font-medium">{{ authStore.user?.username }}</p>
<p class="text-xs text-gray-400">{{ authStore.user?.email }}</p>
</div>
</div>
</div>
</aside>
</div>
</transition>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import {
HomeFilled,
DataAnalysis,
Management,
DataBoard,
User,
Setting,
DocumentChecked,
Translate,
Bell,
Menu,
Close
} from '@element-plus/icons-vue'
const { t, locale } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const sidebarOpen = ref(false)
const notifications = ref(3)
// Full menu items for sidebar
const menuItems = [
{ path: '/', label: 'menu.dashboard', icon: HomeFilled },
{ path: '/campaigns', label: 'menu.campaigns', icon: Management },
{ path: '/analytics', label: 'menu.analytics', icon: DataAnalysis },
{ path: '/ab-testing', label: 'menu.abTesting', icon: DataBoard },
{ path: '/accounts', label: 'menu.accounts', icon: User },
{ path: '/compliance', label: 'menu.compliance', icon: DocumentChecked },
{ path: '/settings', label: 'menu.settings', icon: Setting }
]
// Bottom navigation items (most important)
const bottomNavItems = [
{ path: '/', label: 'menu.dashboard', icon: HomeFilled },
{ path: '/campaigns', label: 'menu.campaigns', icon: Management },
{ path: '/analytics', label: 'menu.analytics', icon: DataAnalysis },
{ path: '/accounts', label: 'menu.accounts', icon: User }
]
const userInitial = computed(() => {
return authStore.user?.username?.charAt(0).toUpperCase() || 'U'
})
const currentLanguage = computed(() => {
return locale.value === 'zh' ? '中文' : 'English'
})
const handleUserCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/settings?tab=profile')
break
case 'settings':
router.push('/settings')
break
case 'language':
locale.value = locale.value === 'zh' ? 'en' : 'zh'
localStorage.setItem('locale', locale.value)
break
case 'logout':
await authStore.logout()
ElMessage.success(t('auth.logoutSuccess'))
break
}
}
</script>
<style scoped>
.slide-enter-active {
transition: opacity 0.3s, transform 0.3s;
}
.slide-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.slide-enter-from {
opacity: 0;
}
.slide-leave-to {
opacity: 0;
}
.slide-enter-from aside {
transform: translateX(-100%);
}
.slide-leave-to aside {
transform: translateX(-100%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="min-h-screen bg-gray-100">
<!-- Simple Header -->
<header class="bg-white shadow">
<div class="px-4 py-4 flex justify-between items-center">
<h1 class="text-xl font-bold">Marketing Agent System</h1>
<div class="flex items-center space-x-4">
<span>{{ username }}</span>
<button @click="logout" class="text-red-600 hover:text-red-800">
Logout
</button>
</div>
</div>
</header>
<!-- Simple Navigation -->
<nav class="bg-gray-800 text-white px-4 py-2">
<div class="flex space-x-4">
<router-link to="/dashboard" class="hover:bg-gray-700 px-3 py-1 rounded">
Dashboard
</router-link>
<router-link to="/dashboard/campaigns" class="hover:bg-gray-700 px-3 py-1 rounded">
Campaigns
</router-link>
<router-link to="/dashboard/campaigns/schedules" class="hover:bg-gray-700 px-3 py-1 rounded">
Schedules
</router-link>
<router-link to="/dashboard/analytics" class="hover:bg-gray-700 px-3 py-1 rounded">
Analytics
</router-link>
<router-link to="/dashboard/analytics/realtime" class="hover:bg-gray-700 px-3 py-1 rounded">
Real-time
</router-link>
<router-link to="/dashboard/analytics/reports" class="hover:bg-gray-700 px-3 py-1 rounded">
Reports
</router-link>
<router-link to="/dashboard/workflows" class="hover:bg-gray-700 px-3 py-1 rounded">
Workflows
</router-link>
<router-link to="/dashboard/webhooks" class="hover:bg-gray-700 px-3 py-1 rounded">
Webhooks
</router-link>
<router-link to="/dashboard/templates" class="hover:bg-gray-700 px-3 py-1 rounded">
Templates
</router-link>
<router-link to="/dashboard/translations" class="hover:bg-gray-700 px-3 py-1 rounded">
Translations
</router-link>
<router-link to="/dashboard/users" class="hover:bg-gray-700 px-3 py-1 rounded">
Users
</router-link>
<router-link to="/dashboard/data-exchange" class="hover:bg-gray-700 px-3 py-1 rounded">
Import/Export
</router-link>
<router-link to="/dashboard/settings" class="hover:bg-gray-700 px-3 py-1 rounded">
Settings
</router-link>
</div>
</nav>
<!-- Main Content -->
<main class="p-4">
<router-view />
</main>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const username = computed(() => {
return authStore.user?.username || 'User'
})
const logout = async () => {
await authStore.logout()
router.push('/login')
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
{{ t('auth.login') }}
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Telegram Marketing Agent System
</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
class="mt-8 space-y-6"
@submit.prevent="handleSubmit"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
:placeholder="t('auth.username')"
size="large"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
:placeholder="t('auth.password')"
size="large"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.rememberMe">
{{ t('auth.rememberMe') }}
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
native-type="submit"
class="w-full"
>
{{ t('auth.login') }}
</el-button>
</el-form-item>
<div class="flex items-center justify-between">
<router-link
to="/register"
class="text-sm text-primary-600 hover:text-primary-500"
>
{{ t('auth.register') }}
</router-link>
<a href="#" class="text-sm text-primary-600 hover:text-primary-500">
{{ t('auth.forgotPassword') }}
</a>
</div>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({
username: '',
password: '',
rememberMe: false
})
const rules = {
username: [
{ required: true, message: 'Please enter username', trigger: 'blur' }
],
password: [
{ required: true, message: 'Please enter password', trigger: 'blur' },
{ min: 6, message: 'Password must be at least 6 characters', trigger: 'blur' }
]
}
const handleSubmit = async () => {
const valid = await formRef.value.validate()
if (!valid) return
loading.value = true
try {
const result = await authStore.login({
username: form.username,
password: form.password
})
loading.value = false
console.log('Login result:', result)
if (result.success) {
ElMessage.success(t('auth.loginSuccess'))
const redirect = route.query.redirect || '/'
console.log('Redirecting to:', redirect)
// Add delay to see message
setTimeout(() => {
router.push(redirect).catch(err => {
console.error('Router push error:', err)
ElMessage.error('Navigation failed: ' + err.message)
})
}, 1000)
} else {
ElMessage.error(result.message)
}
} catch (error) {
loading.value = false
console.error('Login error:', error)
ElMessage.error('Login failed: ' + error.message)
}
}
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="p-8">
<h1 class="text-2xl mb-4">Login Debug</h1>
<div class="space-y-4">
<div>
<label class="block">Username:</label>
<input v-model="username" type="text" class="border p-2 w-full" />
</div>
<div>
<label class="block">Password:</label>
<input v-model="password" type="password" class="border p-2 w-full" />
</div>
<button @click="testLogin" class="bg-blue-500 text-white px-4 py-2 rounded">
Test Login
</button>
<div v-if="result" class="mt-4 p-4 border rounded">
<h3 class="font-bold">Result:</h3>
<pre>{{ JSON.stringify(result, null, 2) }}</pre>
</div>
<div v-if="error" class="mt-4 p-4 border border-red-500 rounded text-red-500">
<h3 class="font-bold">Error:</h3>
<pre>{{ error }}</pre>
</div>
<div class="mt-4">
<h3 class="font-bold">Auth Store State:</h3>
<pre>{{ JSON.stringify(authStoreState, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import api from '@/api'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('admin')
const password = ref('admin123456')
const result = ref(null)
const error = ref(null)
const authStoreState = computed(() => ({
isAuthenticated: authStore.isAuthenticated,
token: authStore.token,
user: authStore.user
}))
const testLogin = async () => {
result.value = null
error.value = null
try {
console.log('Starting login...')
// Direct API call
const response = await api.auth.login({
username: username.value,
password: password.value
})
console.log('API Response:', response)
result.value = response
// Try auth store login
const storeResult = await authStore.login({
username: username.value,
password: password.value
})
console.log('Store Result:', storeResult)
if (storeResult.success) {
console.log('Login successful, redirecting...')
setTimeout(() => {
router.push('/')
}, 2000)
}
} catch (err) {
console.error('Login error:', err)
error.value = err.message || err.toString()
}
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="text-center">
<h1 class="text-6xl font-bold text-gray-900">404</h1>
<p class="mt-2 text-xl text-gray-600">Page not found</p>
<p class="mt-2 text-gray-500">The page you are looking for doesn't exist.</p>
<div class="mt-6">
<router-link
to="/"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
>
Go back home
</router-link>
</div>
</div>
</div>
</template>
<script setup>
// No logic needed for 404 page
</script>

View File

@@ -0,0 +1,187 @@
<template>
<div class="settings-page">
<el-card class="page-header">
<h1>{{ $t('settings.title') }}</h1>
<p>{{ $t('settings.subtitle') }}</p>
</el-card>
<el-tabs v-model="activeTab">
<el-tab-pane :label="$t('settings.general')" name="general">
<el-card>
<el-form :model="settings" label-width="200px">
<el-form-item :label="$t('settings.language')">
<el-select v-model="settings.language" @change="changeLanguage">
<el-option label="English" value="en" />
<el-option label="中文" value="zh" />
</el-select>
</el-form-item>
<el-form-item :label="$t('settings.timezone')">
<el-select v-model="settings.timezone">
<el-option label="UTC" value="UTC" />
<el-option label="America/New_York" value="America/New_York" />
<el-option label="Asia/Shanghai" value="Asia/Shanghai" />
</el-select>
</el-form-item>
<el-form-item :label="$t('settings.defaultCampaignDuration')">
<el-input-number v-model="settings.defaultCampaignDuration" :min="1" :max="365" />
<span class="ml-2">{{ $t('common.days') }}</span>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<el-tab-pane :label="$t('settings.apiKeys')" name="api">
<el-card>
<el-table :data="apiKeys" style="width: 100%">
<el-table-column prop="name" :label="$t('settings.name')" />
<el-table-column prop="key" :label="$t('settings.key')">
<template #default="{ row }">
<span>{{ maskApiKey(row.key) }}</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" :label="$t('settings.createdAt')" />
<el-table-column :label="$t('settings.actions')" width="150">
<template #default="{ row }">
<el-button size="small" type="danger" @click="deleteApiKey(row)">
{{ $t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-button type="primary" @click="generateApiKey" class="mt-4">
{{ $t('settings.generateApiKey') }}
</el-button>
</el-card>
</el-tab-pane>
<el-tab-pane :label="$t('settings.notifications')" name="notifications">
<el-card>
<el-form :model="settings" label-width="200px">
<el-form-item :label="$t('settings.emailNotifications')">
<el-switch v-model="settings.emailNotifications" />
</el-form-item>
<el-form-item :label="$t('settings.campaignCompletionAlert')">
<el-switch v-model="settings.campaignCompletionAlert" />
</el-form-item>
<el-form-item :label="$t('settings.errorNotifications')">
<el-switch v-model="settings.errorNotifications" />
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
<div class="mt-4">
<el-button type="primary" @click="saveSettings">
{{ $t('common.save') }}
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import api from '@/api'
const { locale } = useI18n()
const activeTab = ref('general')
const settings = ref({
language: 'en',
timezone: 'UTC',
defaultCampaignDuration: 7,
emailNotifications: true,
campaignCompletionAlert: true,
errorNotifications: true
})
const apiKeys = ref([])
const loadSettings = async () => {
try {
const response = await api.settings.get()
settings.value = response.data
} catch (error) {
ElMessage.error('Failed to load settings')
}
}
const loadApiKeys = async () => {
try {
const response = await api.settings.getApiKeys()
apiKeys.value = response.data
} catch (error) {
ElMessage.error('Failed to load API keys')
}
}
const saveSettings = async () => {
try {
await api.settings.update(settings.value)
ElMessage.success('Settings saved successfully')
} catch (error) {
ElMessage.error('Failed to save settings')
}
}
const changeLanguage = (lang) => {
locale.value = lang
}
const maskApiKey = (key) => {
return key.substring(0, 10) + '...' + key.substring(key.length - 4)
}
const generateApiKey = async () => {
try {
await api.settings.generateApiKey()
ElMessage.success('API key generated successfully')
loadApiKeys()
} catch (error) {
ElMessage.error('Failed to generate API key')
}
}
const deleteApiKey = async (key) => {
try {
await api.settings.deleteApiKey(key.id)
ElMessage.success('API key deleted successfully')
loadApiKeys()
} catch (error) {
ElMessage.error('Failed to delete API key')
}
}
onMounted(() => {
loadSettings()
loadApiKeys()
})
</script>
<style lang="scss" scoped>
.settings-page {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
h1 {
margin: 0 0 10px 0;
color: var(--el-text-color-primary);
}
p {
margin: 0;
color: var(--el-text-color-regular);
}
}
.mt-4 {
margin-top: 16px;
}
.ml-2 {
margin-left: 8px;
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<div>
<h1>Test Simple Page</h1>
<p>If you can see this, routing works!</p>
<router-link to="/login">Go to Login</router-link>
</div>
</template>
<script setup>
console.log('TestSimple component loaded')
</script>

View File

@@ -0,0 +1,622 @@
<template>
<div class="experiment-details" v-loading="loading">
<div v-if="experiment">
<!-- Header -->
<el-page-header @back="handleBack">
<template #content>
<div class="page-header-content">
<h1>{{ experiment.name }}</h1>
<el-tag :type="getStatusType(experiment.status)" size="large">
{{ experiment.status }}
</el-tag>
</div>
</template>
<template #extra>
<el-button-group>
<el-button
v-if="experiment.status === 'draft'"
type="primary"
@click="handleStart"
>
Start Experiment
</el-button>
<el-button
v-else-if="experiment.status === 'running'"
type="danger"
@click="handleStop"
>
Stop Experiment
</el-button>
<el-button @click="handleRefresh">
<el-icon><Refresh /></el-icon>
Refresh
</el-button>
<el-dropdown trigger="click" class="ml-2">
<el-button>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleEdit" :disabled="experiment.status === 'running'">
Edit
</el-dropdown-item>
<el-dropdown-item @click="handleDuplicate">
Duplicate
</el-dropdown-item>
<el-dropdown-item @click="handleExport">
Export Results
</el-dropdown-item>
<el-dropdown-item
@click="handleDelete"
:disabled="!['draft', 'archived'].includes(experiment.status)"
divided
>
Delete
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-button-group>
</template>
</el-page-header>
<!-- Overview -->
<el-row :gutter="20" class="mt-4">
<el-col :span="16">
<el-card>
<template #header>
<h3>Overview</h3>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="Description">
{{ experiment.description || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Hypothesis">
{{ experiment.hypothesis || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Type">
{{ getExperimentType(experiment.type) }}
</el-descriptions-item>
<el-descriptions-item label="Target Metric">
{{ experiment.targetMetric.name }}
<el-tag size="small" type="info" class="ml-2">
{{ experiment.targetMetric.goalDirection }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Start Date">
{{ formatDate(experiment.startDate) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="End Date">
{{ formatDate(experiment.endDate) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Confidence Level">
{{ (experiment.requirements.confidenceLevel * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="Min Sample Size">
{{ experiment.requirements.minimumSampleSize }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<template #header>
<h3>Status</h3>
</template>
<div v-if="status">
<el-statistic
title="Overall Progress"
:value="status.progress.percentage"
suffix="%"
/>
<el-progress
:percentage="parseFloat(status.progress.percentage)"
:status="parseFloat(status.progress.percentage) === 100 ? 'success' : ''"
class="mt-2"
/>
<el-row :gutter="20" class="mt-4">
<el-col :span="12">
<el-statistic
title="Participants"
:value="status.overall.participants"
/>
</el-col>
<el-col :span="12">
<el-statistic
title="Conversions"
:value="status.overall.conversions"
/>
</el-col>
</el-row>
<el-divider />
<el-statistic
title="Days Running"
:value="status.progress.daysRunning"
v-if="experiment.status === 'running'"
/>
</div>
<el-empty v-else description="No data yet" />
</el-card>
</el-col>
</el-row>
<!-- Variants Performance -->
<el-card class="mt-4">
<template #header>
<div class="card-header">
<h3>Variants Performance</h3>
<el-button
v-if="experiment.status === 'running'"
size="small"
@click="loadStatus"
>
Refresh Data
</el-button>
</div>
</template>
<el-table :data="variantStats" style="width: 100%">
<el-table-column prop="name" label="Variant" width="200">
<template #default="{ row }">
<div>
<strong>{{ row.name }}</strong>
<el-tag
v-if="row.variantId === experiment.control"
size="small"
type="info"
class="ml-2"
>
Control
</el-tag>
<el-tag
v-if="experiment.results?.winner === row.variantId"
size="small"
type="success"
class="ml-2"
>
Winner
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="participants" label="Participants" width="120" align="center" />
<el-table-column prop="conversions" label="Conversions" width="120" align="center" />
<el-table-column label="Conversion Rate" width="150" align="center">
<template #default="{ row }">
<el-statistic
:value="row.conversionRate"
:precision="2"
suffix="%"
/>
</template>
</el-table-column>
<el-table-column label="Improvement" width="150" align="center">
<template #default="{ row }">
<div v-if="row.variantId !== experiment.control && controlVariant">
<el-statistic
:value="calculateImprovement(row, controlVariant)"
:precision="2"
suffix="%"
:value-style="{
color: calculateImprovement(row, controlVariant) > 0 ? '#67c23a' : '#f56c6c'
}"
/>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="Statistical Significance" min-width="200">
<template #default="{ row }">
<div v-if="row.variantId !== experiment.control && results?.analysis">
<el-tag
:type="getSignificanceType(row.variantId)"
>
{{ getSignificanceLabel(row.variantId) }}
</el-tag>
<span class="ml-2 text-small text-gray">
p-value: {{ getPValue(row.variantId) }}
</span>
</div>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<!-- Results Summary -->
<div v-if="experiment.results?.summary" class="results-summary">
<el-alert
:title="experiment.results.summary"
type="success"
show-icon
:closable="false"
/>
<div v-if="experiment.results.recommendations?.length" class="mt-4">
<h4>Recommendations</h4>
<ul>
<li v-for="(rec, index) in experiment.results.recommendations" :key="index">
{{ rec }}
</li>
</ul>
</div>
</div>
</el-card>
<!-- Results Chart -->
<el-card class="mt-4" v-if="chartData">
<template #header>
<h3>Conversion Rate Over Time</h3>
</template>
<div class="chart-container">
<ConversionChart :data="chartData" />
</div>
</el-card>
</div>
<!-- Edit Dialog -->
<EditExperimentDialog
v-model="showEditDialog"
:experiment="experiment"
@updated="handleUpdated"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, More } from '@element-plus/icons-vue'
import { useABTestingStore } from '@/stores/abTesting'
import EditExperimentDialog from './components/EditExperimentDialog.vue'
import ConversionChart from './components/ConversionChart.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const abTestingStore = useABTestingStore()
// State
const loading = ref(false)
const experiment = ref(null)
const status = ref(null)
const results = ref(null)
const showEditDialog = ref(false)
const chartData = ref(null)
// Computed
const experimentId = computed(() => route.params.id)
const variantStats = computed(() => {
if (!status.value?.variants) return []
return status.value.variants.map(v => {
const variant = experiment.value.variants.find(ev => ev.variantId === v.variantId)
return {
...v,
name: variant?.name || v.variantId,
description: variant?.description
}
})
})
const controlVariant = computed(() => {
return variantStats.value.find(v => v.variantId === experiment.value?.control)
})
// Methods
const loadExperiment = async () => {
loading.value = true
try {
experiment.value = await abTestingStore.fetchExperiment(experimentId.value)
if (experiment.value.status !== 'draft') {
await loadStatus()
await loadResults()
}
} catch (error) {
ElMessage.error('Failed to load experiment')
router.push('/ab-testing')
} finally {
loading.value = false
}
}
const loadStatus = async () => {
try {
status.value = await abTestingStore.fetchExperimentStatus(experimentId.value)
} catch (error) {
console.error('Failed to load status:', error)
}
}
const loadResults = async () => {
try {
results.value = await abTestingStore.fetchExperimentResults(experimentId.value)
// Generate chart data if available
if (results.value?.timeSeriesData) {
chartData.value = generateChartData(results.value.timeSeriesData)
}
} catch (error) {
console.error('Failed to load results:', error)
}
}
const handleBack = () => {
router.push('/ab-testing')
}
const handleRefresh = () => {
loadExperiment()
}
const handleStart = async () => {
try {
await ElMessageBox.confirm(
'Are you sure you want to start this experiment?',
'Start Experiment',
{
confirmButtonText: 'Start',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
await abTestingStore.startExperiment(experimentId.value)
ElMessage.success('Experiment started successfully')
await loadExperiment()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to start experiment')
}
}
}
const handleStop = async () => {
try {
const { value: reason } = await ElMessageBox.prompt(
'Why are you stopping this experiment?',
'Stop Experiment',
{
confirmButtonText: 'Stop',
cancelButtonText: 'Cancel',
inputPlaceholder: 'Optional reason...',
type: 'warning'
}
)
await abTestingStore.stopExperiment(experimentId.value, reason)
ElMessage.success('Experiment stopped successfully')
await loadExperiment()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to stop experiment')
}
}
}
const handleEdit = () => {
showEditDialog.value = true
}
const handleUpdated = () => {
showEditDialog.value = false
loadExperiment()
}
const handleDuplicate = () => {
// TODO: Implement duplicate functionality
ElMessage.info('Duplicate functionality coming soon')
}
const handleExport = async () => {
try {
await abTestingStore.exportResults(experimentId.value, 'csv')
ElMessage.success('Results exported successfully')
} catch (error) {
ElMessage.error('Failed to export results')
}
}
const handleDelete = async () => {
try {
await ElMessageBox.confirm(
'Are you sure you want to delete this experiment? This action cannot be undone.',
'Delete Experiment',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
await abTestingStore.deleteExperiment(experimentId.value)
ElMessage.success('Experiment deleted successfully')
router.push('/ab-testing')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete experiment')
}
}
}
// Helpers
const formatDate = (date) => {
return date ? dayjs(date).format('YYYY-MM-DD HH:mm') : null
}
const getStatusType = (status) => {
const types = {
draft: 'info',
running: 'success',
completed: '',
archived: 'info'
}
return types[status] || ''
}
const getExperimentType = (type) => {
const types = {
ab: 'A/B Test',
multivariate: 'Multivariate',
bandit: 'Multi-Armed Bandit'
}
return types[type] || type
}
const calculateImprovement = (variant, control) => {
if (!control || control.conversionRate === 0) return 0
return ((variant.conversionRate - control.conversionRate) / control.conversionRate) * 100
}
const getSignificanceType = (variantId) => {
const analysis = results.value?.analysis?.variants?.[variantId]
if (!analysis?.comparisonToControl) return 'info'
return analysis.comparisonToControl.significant ? 'success' : 'warning'
}
const getSignificanceLabel = (variantId) => {
const analysis = results.value?.analysis?.variants?.[variantId]
if (!analysis?.comparisonToControl) return 'Not tested'
return analysis.comparisonToControl.significant ? 'Significant' : 'Not significant'
}
const getPValue = (variantId) => {
const analysis = results.value?.analysis?.variants?.[variantId]
if (!analysis?.comparisonToControl) return '-'
return analysis.comparisonToControl.pValue.toFixed(4)
}
const generateChartData = (timeSeriesData) => {
// Transform time series data for chart
const labels = timeSeriesData.map(d => dayjs(d.date).format('MMM DD'))
const datasets = []
const variants = Object.keys(timeSeriesData[0]?.variants || {})
variants.forEach((variantId, index) => {
const variant = experiment.value.variants.find(v => v.variantId === variantId)
datasets.push({
label: variant?.name || variantId,
data: timeSeriesData.map(d => d.variants[variantId]?.conversionRate || 0),
borderColor: getChartColor(index),
backgroundColor: getChartColor(index, 0.1)
})
})
return { labels, datasets }
}
const getChartColor = (index, opacity = 1) => {
const colors = [
`rgba(54, 162, 235, ${opacity})`,
`rgba(255, 99, 132, ${opacity})`,
`rgba(255, 206, 86, ${opacity})`,
`rgba(75, 192, 192, ${opacity})`,
`rgba(153, 102, 255, ${opacity})`
]
return colors[index % colors.length]
}
// Auto-refresh for running experiments
let refreshInterval = null
watch(() => experiment.value?.status, (status) => {
if (status === 'running') {
refreshInterval = setInterval(() => {
loadStatus()
loadResults()
}, 30000) // Refresh every 30 seconds
} else if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
})
// Lifecycle
onMounted(() => {
loadExperiment()
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<style lang="scss" scoped>
.experiment-details {
padding: 20px;
.page-header-content {
display: flex;
align-items: center;
gap: 16px;
h1 {
margin: 0;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
}
}
.results-summary {
margin-top: 20px;
padding: 20px;
background: #f5f7fa;
border-radius: 4px;
h4 {
margin-top: 0;
}
ul {
margin: 10px 0;
padding-left: 20px;
}
}
.chart-container {
height: 400px;
}
.mt-4 {
margin-top: 16px;
}
.ml-2 {
margin-left: 8px;
}
.text-small {
font-size: 12px;
}
.text-gray {
color: #909399;
}
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<div class="experiment-list">
<el-card>
<template #header>
<div class="card-header">
<h2>A/B Testing Experiments</h2>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
Create Experiment
</el-button>
</div>
</template>
<!-- Filters -->
<el-row :gutter="20" class="filter-row">
<el-col :span="6">
<el-select v-model="filters.status" placeholder="Filter by status" clearable>
<el-option label="All" value="" />
<el-option label="Draft" value="draft" />
<el-option label="Running" value="running" />
<el-option label="Completed" value="completed" />
<el-option label="Archived" value="archived" />
</el-select>
</el-col>
<el-col :span="6">
<el-select v-model="filters.type" placeholder="Filter by type" clearable>
<el-option label="All" value="" />
<el-option label="A/B Test" value="ab" />
<el-option label="Multivariate" value="multivariate" />
<el-option label="Multi-Armed Bandit" value="bandit" />
</el-select>
</el-col>
<el-col :span="12">
<el-input
v-model="filters.search"
placeholder="Search experiments..."
clearable
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-col>
</el-row>
<!-- Experiments Table -->
<el-table
:data="experiments"
v-loading="loading"
style="width: 100%"
@row-click="handleRowClick"
>
<el-table-column prop="name" label="Name" min-width="200">
<template #default="{ row }">
<div>
<strong>{{ row.name }}</strong>
<div class="text-small text-gray">{{ row.description }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="Status" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="type" label="Type" width="120">
<template #default="{ row }">
<el-tag type="info">{{ getExperimentType(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Variants" width="100" align="center">
<template #default="{ row }">
{{ row.variants.length }}
</template>
</el-table-column>
<el-table-column label="Progress" width="200">
<template #default="{ row }">
<div v-if="row.status === 'running'">
<el-progress
:percentage="getProgress(row)"
:status="getProgress(row) === 100 ? 'success' : ''"
/>
<div class="text-small text-gray">
{{ getTotalParticipants(row) }} participants
</div>
</div>
<div v-else class="text-gray">-</div>
</template>
</el-table-column>
<el-table-column label="Results" min-width="150">
<template #default="{ row }">
<div v-if="row.results?.winner">
<el-tag type="success">
Winner: {{ getVariantName(row, row.results.winner) }}
</el-tag>
<div class="text-small text-gray">
{{ row.results.summary }}
</div>
</div>
<div v-else-if="row.status === 'running'">
<span class="text-gray">In progress...</span>
</div>
<div v-else class="text-gray">-</div>
</template>
</el-table-column>
<el-table-column label="Actions" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button
v-if="row.status === 'draft'"
size="small"
type="primary"
@click.stop="startExperiment(row)"
>
Start
</el-button>
<el-button
v-else-if="row.status === 'running'"
size="small"
type="danger"
@click.stop="stopExperiment(row)"
>
Stop
</el-button>
<el-button
size="small"
@click.stop="viewDetails(row)"
>
Details
</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
<!-- Pagination -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadExperiments"
@current-change="loadExperiments"
/>
</el-card>
<!-- Create Experiment Dialog -->
<CreateExperimentDialog
v-model="showCreateDialog"
@created="handleExperimentCreated"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { useABTestingStore } from '@/stores/abTesting'
import CreateExperimentDialog from './components/CreateExperimentDialog.vue'
const router = useRouter()
const abTestingStore = useABTestingStore()
// State
const loading = ref(false)
const experiments = ref([])
const showCreateDialog = ref(false)
const filters = reactive({
status: '',
type: '',
search: ''
})
const pagination = reactive({
page: 1,
limit: 20,
total: 0
})
// Methods
const loadExperiments = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
limit: pagination.limit,
...filters
}
const response = await abTestingStore.fetchExperiments(params)
experiments.value = response.experiments
pagination.total = response.pagination.total
} catch (error) {
ElMessage.error('Failed to load experiments')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadExperiments()
}
const handleRowClick = (row) => {
router.push(`/ab-testing/${row.experimentId}`)
}
const viewDetails = (experiment) => {
router.push(`/ab-testing/${experiment.experimentId}`)
}
const startExperiment = async (experiment) => {
try {
await ElMessageBox.confirm(
'Are you sure you want to start this experiment?',
'Start Experiment',
{
confirmButtonText: 'Start',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
await abTestingStore.startExperiment(experiment.experimentId)
ElMessage.success('Experiment started successfully')
await loadExperiments()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to start experiment')
}
}
}
const stopExperiment = async (experiment) => {
try {
const { value: reason } = await ElMessageBox.prompt(
'Why are you stopping this experiment?',
'Stop Experiment',
{
confirmButtonText: 'Stop',
cancelButtonText: 'Cancel',
inputPlaceholder: 'Optional reason...',
type: 'warning'
}
)
await abTestingStore.stopExperiment(experiment.experimentId, reason)
ElMessage.success('Experiment stopped successfully')
await loadExperiments()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to stop experiment')
}
}
}
const handleExperimentCreated = () => {
showCreateDialog.value = false
loadExperiments()
}
// Computed
const getStatusType = (status) => {
const types = {
draft: 'info',
running: 'success',
completed: '',
archived: 'info'
}
return types[status] || ''
}
const getExperimentType = (type) => {
const types = {
ab: 'A/B Test',
multivariate: 'Multivariate',
bandit: 'Multi-Armed Bandit'
}
return types[type] || type
}
const getProgress = (experiment) => {
if (!experiment.requirements?.minimumSampleSize) return 0
const total = getTotalParticipants(experiment)
return Math.min(100, Math.round((total / experiment.requirements.minimumSampleSize) * 100))
}
const getTotalParticipants = (experiment) => {
return experiment.variants.reduce((sum, v) => sum + (v.metrics?.participants || 0), 0)
}
const getVariantName = (experiment, variantId) => {
const variant = experiment.variants.find(v => v.variantId === variantId)
return variant?.name || variantId
}
// Lifecycle
onMounted(() => {
loadExperiments()
})
</script>
<style lang="scss" scoped>
.experiment-list {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
}
}
.filter-row {
margin-bottom: 20px;
}
.text-small {
font-size: 12px;
}
.text-gray {
color: #909399;
}
.el-table {
margin-bottom: 20px;
:deep(.el-table__row) {
cursor: pointer;
}
}
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<el-dialog
v-model="visible"
title="Create A/B Test Experiment"
width="800px"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="140px"
>
<!-- Basic Information -->
<el-divider content-position="left">Basic Information</el-divider>
<el-form-item label="Name" prop="name">
<el-input v-model="form.name" placeholder="Enter experiment name" />
</el-form-item>
<el-form-item label="Description" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="Brief description of the experiment"
/>
</el-form-item>
<el-form-item label="Hypothesis" prop="hypothesis">
<el-input
v-model="form.hypothesis"
type="textarea"
:rows="2"
placeholder="What do you expect to happen?"
/>
</el-form-item>
<el-form-item label="Type" prop="type">
<el-radio-group v-model="form.type">
<el-radio value="ab">A/B Test</el-radio>
<el-radio value="multivariate">Multivariate</el-radio>
<el-radio value="bandit">Multi-Armed Bandit</el-radio>
</el-radio-group>
</el-form-item>
<!-- Target Metric -->
<el-divider content-position="left">Target Metric</el-divider>
<el-form-item label="Metric Name" prop="targetMetric.name">
<el-input v-model="form.targetMetric.name" placeholder="e.g., Conversion Rate" />
</el-form-item>
<el-form-item label="Metric Type" prop="targetMetric.type">
<el-select v-model="form.targetMetric.type">
<el-option label="Conversion" value="conversion" />
<el-option label="Revenue" value="revenue" />
<el-option label="Engagement" value="engagement" />
<el-option label="Custom" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="Goal Direction" prop="targetMetric.goalDirection">
<el-radio-group v-model="form.targetMetric.goalDirection">
<el-radio value="increase">Increase</el-radio>
<el-radio value="decrease">Decrease</el-radio>
</el-radio-group>
</el-form-item>
<!-- Variants -->
<el-divider content-position="left">Variants</el-divider>
<el-form-item label="Variants" required>
<div class="variants-container">
<div
v-for="(variant, index) in form.variants"
:key="variant.id"
class="variant-item"
>
<el-card>
<template #header>
<div class="variant-header">
<span>Variant {{ index + 1 }}</span>
<div>
<el-radio
v-model="form.control"
:label="variant.variantId"
>
Control
</el-radio>
<el-button
v-if="form.variants.length > 2"
type="danger"
size="small"
text
@click="removeVariant(index)"
>
Remove
</el-button>
</div>
</div>
</template>
<el-form-item label="Name" :prop="`variants.${index}.name`" required>
<el-input v-model="variant.name" placeholder="Variant name" />
</el-form-item>
<el-form-item label="Description">
<el-input
v-model="variant.description"
type="textarea"
:rows="2"
placeholder="What's different in this variant?"
/>
</el-form-item>
<el-form-item label="Allocation %" :prop="`variants.${index}.allocation.percentage`" required>
<el-input-number
v-model="variant.allocation.percentage"
:min="0"
:max="100"
:precision="1"
/>
<span class="ml-2">%</span>
</el-form-item>
</el-card>
</div>
<el-button
type="dashed"
@click="addVariant"
style="width: 100%"
>
<el-icon><Plus /></el-icon>
Add Variant
</el-button>
<el-alert
v-if="allocationError"
type="error"
:title="allocationError"
show-icon
:closable="false"
/>
</div>
</el-form-item>
<!-- Allocation Method -->
<el-form-item label="Allocation Method" prop="allocation.method">
<el-select v-model="form.allocation.method">
<el-option label="Random" value="random" />
<el-option label="Epsilon-Greedy" value="epsilon-greedy" />
<el-option label="Upper Confidence Bound (UCB)" value="ucb" />
<el-option label="Thompson Sampling" value="thompson" />
</el-select>
</el-form-item>
<!-- Statistical Requirements -->
<el-divider content-position="left">Statistical Requirements</el-divider>
<el-form-item label="Confidence Level">
<el-select v-model="form.requirements.confidenceLevel">
<el-option label="90%" :value="0.90" />
<el-option label="95%" :value="0.95" />
<el-option label="99%" :value="0.99" />
</el-select>
</el-form-item>
<el-form-item label="Statistical Power">
<el-select v-model="form.requirements.statisticalPower">
<el-option label="80%" :value="0.80" />
<el-option label="85%" :value="0.85" />
<el-option label="90%" :value="0.90" />
</el-select>
</el-form-item>
<el-form-item label="Min Detectable Effect">
<el-input-number
v-model="form.requirements.minimumDetectableEffect"
:min="0.01"
:max="1"
:step="0.01"
:precision="2"
/>
<span class="ml-2">({{ (form.requirements.minimumDetectableEffect * 100).toFixed(0) }}%)</span>
</el-form-item>
<!-- Advanced Settings -->
<el-collapse v-model="activeCollapse">
<el-collapse-item title="Advanced Settings" name="advanced">
<el-form-item label="Stop on Significance">
<el-switch v-model="form.settings.stopOnSignificance" />
<span class="ml-2 text-gray">Automatically stop when results are significant</span>
</el-form-item>
<el-form-item label="Multiple Testing">
<el-select v-model="form.settings.multipleTestingCorrection">
<el-option label="None" value="none" />
<el-option label="Bonferroni" value="bonferroni" />
<el-option label="Benjamini-Hochberg" value="benjamini-hochberg" />
</el-select>
</el-form-item>
<el-form-item label="Target Audience %">
<el-input-number
v-model="form.targetAudience.percentage"
:min="1"
:max="100"
/>
<span class="ml-2">% of eligible users</span>
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<el-button @click="handleClose">Cancel</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">
Create Experiment
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useABTestingStore } from '@/stores/abTesting'
import { v4 as uuidv4 } from 'uuid'
// Props & Emits
const props = defineProps({
modelValue: Boolean
})
const emit = defineEmits(['update:modelValue', 'created'])
// State
const abTestingStore = useABTestingStore()
const formRef = ref()
const loading = ref(false)
const activeCollapse = ref([])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const form = reactive({
name: '',
description: '',
hypothesis: '',
type: 'ab',
targetMetric: {
name: '',
type: 'conversion',
goalDirection: 'increase'
},
variants: [
{
variantId: `variant_${uuidv4()}`,
name: 'Control',
description: '',
allocation: {
percentage: 50,
method: 'fixed'
}
},
{
variantId: `variant_${uuidv4()}`,
name: 'Variant A',
description: '',
allocation: {
percentage: 50,
method: 'fixed'
}
}
],
control: '',
allocation: {
method: 'random',
parameters: {}
},
requirements: {
confidenceLevel: 0.95,
statisticalPower: 0.80,
minimumDetectableEffect: 0.05
},
settings: {
stopOnSignificance: false,
enableBayesian: true,
multipleTestingCorrection: 'none'
},
targetAudience: {
percentage: 100,
filters: [],
segments: []
}
})
// Set default control
form.control = form.variants[0].variantId
// Validation Rules
const rules = {
name: [
{ required: true, message: 'Please enter experiment name', trigger: 'blur' }
],
'targetMetric.name': [
{ required: true, message: 'Please enter metric name', trigger: 'blur' }
]
}
// Computed
const allocationError = computed(() => {
const total = form.variants.reduce((sum, v) => sum + (v.allocation?.percentage || 0), 0)
if (Math.abs(total - 100) > 0.01) {
return `Variant allocations must sum to 100% (currently ${total.toFixed(1)}%)`
}
return null
})
// Methods
const addVariant = () => {
const newVariant = {
variantId: `variant_${uuidv4()}`,
name: `Variant ${String.fromCharCode(65 + form.variants.length - 1)}`,
description: '',
allocation: {
percentage: 0,
method: 'fixed'
}
}
form.variants.push(newVariant)
}
const removeVariant = (index) => {
const variant = form.variants[index]
if (form.control === variant.variantId) {
// If removing control, set first variant as control
form.control = form.variants[0].variantId
}
form.variants.splice(index, 1)
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (allocationError.value) {
ElMessage.error(allocationError.value)
return
}
loading.value = true
await abTestingStore.createExperiment(form)
ElMessage.success('Experiment created successfully')
emit('created')
handleClose()
} catch (error) {
if (error !== false) { // Validation error returns false
ElMessage.error('Failed to create experiment')
}
} finally {
loading.value = false
}
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
// Reset form to defaults
Object.assign(form, {
name: '',
description: '',
hypothesis: '',
type: 'ab',
targetMetric: {
name: '',
type: 'conversion',
goalDirection: 'increase'
},
variants: [
{
variantId: `variant_${uuidv4()}`,
name: 'Control',
description: '',
allocation: {
percentage: 50,
method: 'fixed'
}
},
{
variantId: `variant_${uuidv4()}`,
name: 'Variant A',
description: '',
allocation: {
percentage: 50,
method: 'fixed'
}
}
],
control: '',
allocation: {
method: 'random',
parameters: {}
},
requirements: {
confidenceLevel: 0.95,
statisticalPower: 0.80,
minimumDetectableEffect: 0.05
},
settings: {
stopOnSignificance: false,
enableBayesian: true,
multipleTestingCorrection: 'none'
},
targetAudience: {
percentage: 100,
filters: [],
segments: []
}
})
form.control = form.variants[0].variantId
}
</script>
<style lang="scss" scoped>
.variants-container {
width: 100%;
.variant-item {
margin-bottom: 16px;
}
.variant-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.ml-2 {
margin-left: 8px;
}
.text-gray {
color: #909399;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,370 @@
<template>
<div class="pb-20">
<!-- Header -->
<div class="sticky top-0 bg-white z-10 px-4 py-3 border-b">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">{{ t('menu.analytics') }}</h1>
<el-dropdown @command="handlePeriodChange" size="small">
<el-button size="small" text>
{{ currentPeriodLabel }}
<el-icon class="ml-1"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="today">Today</el-dropdown-item>
<el-dropdown-item command="yesterday">Yesterday</el-dropdown-item>
<el-dropdown-item command="7d">Last 7 days</el-dropdown-item>
<el-dropdown-item command="30d">Last 30 days</el-dropdown-item>
<el-dropdown-item command="90d">Last 90 days</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- Metrics Cards -->
<div class="p-4">
<div class="grid grid-cols-2 gap-3 mb-4">
<div
v-for="metric in keyMetrics"
:key="metric.id"
class="bg-white rounded-lg p-4 border"
>
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500">{{ metric.label }}</span>
<el-icon :size="16" :style="{ color: metric.color }">
<component :is="metric.icon" />
</el-icon>
</div>
<div class="text-xl font-bold">{{ metric.value }}</div>
<div class="flex items-center mt-1">
<el-icon
:size="12"
:style="{ color: metric.trend > 0 ? '#10b981' : '#ef4444' }"
>
<TrendCharts />
</el-icon>
<span
class="text-xs ml-1"
:style="{ color: metric.trend > 0 ? '#10b981' : '#ef4444' }"
>
{{ Math.abs(metric.trend) }}%
</span>
</div>
</div>
</div>
<!-- Chart Section -->
<el-card class="mb-4" :body-style="{ padding: '12px' }">
<template #header>
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Message Trends</span>
<el-button-group size="small">
<el-button
v-for="type in chartTypes"
:key="type.value"
:type="selectedChartType === type.value ? 'primary' : ''"
@click="selectedChartType = type.value"
>
{{ type.label }}
</el-button>
</el-button-group>
</div>
</template>
<div class="h-48">
<LineChart
v-if="selectedChartType === 'line'"
:data="chartData"
:options="mobileChartOptions"
/>
<Bar
v-else
:data="chartData"
:options="mobileChartOptions"
/>
</div>
</el-card>
<!-- Campaign Performance -->
<el-card class="mb-4" :body-style="{ padding: '12px' }">
<template #header>
<span class="text-sm font-medium">Top Campaigns</span>
</template>
<div class="space-y-3">
<div
v-for="campaign in topCampaigns"
:key="campaign.id"
class="flex items-center justify-between"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{{ campaign.name }}
</p>
<p class="text-xs text-gray-500">
{{ formatNumber(campaign.sent) }} sent
</p>
</div>
<div class="text-right">
<p class="text-sm font-medium">{{ campaign.rate }}%</p>
<p class="text-xs text-gray-500">{{ campaign.metric }}</p>
</div>
</div>
</div>
</el-card>
<!-- Engagement Heatmap -->
<el-card :body-style="{ padding: '12px' }">
<template #header>
<span class="text-sm font-medium">Engagement Heatmap</span>
</template>
<div class="grid grid-cols-7 gap-1">
<div
v-for="(day, index) in weekDays"
:key="index"
class="text-center"
>
<div class="text-xxs text-gray-500 mb-1">{{ day }}</div>
<div class="space-y-1">
<div
v-for="hour in [0, 6, 12, 18]"
:key="hour"
class="w-full h-6 rounded"
:style="{ backgroundColor: getHeatmapColor(index, hour) }"
></div>
</div>
</div>
</div>
<div class="flex items-center justify-center mt-3 text-xxs text-gray-500">
<span>Low</span>
<div class="flex mx-2">
<div class="w-4 h-4 bg-blue-100"></div>
<div class="w-4 h-4 bg-blue-300"></div>
<div class="w-4 h-4 bg-blue-500"></div>
<div class="w-4 h-4 bg-blue-700"></div>
</div>
<span>High</span>
</div>
</el-card>
</div>
<!-- Export Button -->
<div class="fixed bottom-20 right-4 z-10">
<el-dropdown @command="handleExport" placement="top">
<el-button type="primary" circle>
<el-icon><Download /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="pdf">Export as PDF</el-dropdown-item>
<el-dropdown-item command="excel">Export as Excel</el-dropdown-item>
<el-dropdown-item command="csv">Export as CSV</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Line, Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import {
ArrowDown,
TrendCharts,
Message,
User,
View,
ChatDotRound,
Download
} from '@element-plus/icons-vue'
import { formatNumber } from '@/utils/date'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend
)
const { t } = useI18n()
const currentPeriod = ref('7d')
const selectedChartType = ref('line')
const chartTypes = [
{ label: 'Line', value: 'line' },
{ label: 'Bar', value: 'bar' }
]
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const currentPeriodLabel = computed(() => {
const labels = {
today: 'Today',
yesterday: 'Yesterday',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days'
}
return labels[currentPeriod.value]
})
const keyMetrics = ref([
{
id: 1,
label: 'Messages Sent',
value: '45.2K',
trend: 12.5,
icon: Message,
color: '#3b82f6'
},
{
id: 2,
label: 'Unique Recipients',
value: '8.7K',
trend: 8.3,
icon: User,
color: '#10b981'
},
{
id: 3,
label: 'Open Rate',
value: '68%',
trend: -2.1,
icon: View,
color: '#f59e0b'
},
{
id: 4,
label: 'Engagement',
value: '42%',
trend: 5.7,
icon: ChatDotRound,
color: '#8b5cf6'
}
])
const chartData = ref({
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
datasets: [
{
label: 'Messages',
data: [1200, 1900, 1500, 2100, 2400, 2200, 2800],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
}
]
})
const mobileChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: {
size: 12
},
bodyFont: {
size: 11
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
font: {
size: 10
}
}
},
y: {
grid: {
borderDash: [3, 3]
},
ticks: {
font: {
size: 10
}
}
}
}
}
const topCampaigns = ref([
{
id: 1,
name: 'Summer Sale Campaign',
sent: 12500,
rate: 72,
metric: 'Open Rate'
},
{
id: 2,
name: 'Product Launch',
sent: 8300,
rate: 45,
metric: 'Click Rate'
},
{
id: 3,
name: 'Customer Survey',
sent: 5200,
rate: 38,
metric: 'Response Rate'
}
])
const getHeatmapColor = (day, hour) => {
// Mock heatmap data
const intensity = Math.random()
if (intensity < 0.25) return '#dbeafe'
if (intensity < 0.5) return '#93c5fd'
if (intensity < 0.75) return '#3b82f6'
return '#1d4ed8'
}
const handlePeriodChange = (period) => {
currentPeriod.value = period
// Fetch new data based on period
ElMessage.success(`Analytics updated for ${currentPeriodLabel.value}`)
}
const handleExport = (format) => {
ElMessage.success(`Exporting analytics as ${format.toUpperCase()}...`)
// Implement export functionality
}
</script>
<style scoped>
.text-xxs {
font-size: 0.625rem;
line-height: 0.75rem;
}
</style>

View File

@@ -0,0 +1,756 @@
<template>
<div class="realtime-dashboard">
<div class="dashboard-header">
<h1>Real-time Analytics Dashboard</h1>
<div class="header-actions">
<el-select v-model="selectedMetrics" multiple placeholder="Select metrics">
<el-option
v-for="metric in availableMetrics"
:key="metric.value"
:label="metric.label"
:value="metric.value"
/>
</el-select>
<el-button @click="toggleAutoRefresh" :type="autoRefresh ? 'danger' : 'success'">
<el-icon><component :is="autoRefresh ? 'VideoPause' : 'VideoPlay'" /></el-icon>
{{ autoRefresh ? 'Pause' : 'Resume' }}
</el-button>
</div>
</div>
<!-- KPI Cards -->
<el-row :gutter="20" class="kpi-row">
<el-col :span="6">
<el-card class="kpi-card">
<div class="kpi-content">
<div class="kpi-value">
<animated-number :value="kpis.deliveryRate" :format="formatPercentage" />
</div>
<div class="kpi-label">Delivery Rate</div>
<div class="kpi-trend" :class="getTrendClass(kpiTrends.deliveryRate)">
<el-icon><component :is="getTrendIcon(kpiTrends.deliveryRate)" /></el-icon>
{{ Math.abs(kpiTrends.deliveryRate).toFixed(1) }}%
</div>
</div>
<div class="kpi-sparkline">
<sparkline :data="sparklineData.deliveryRate" />
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="kpi-card">
<div class="kpi-content">
<div class="kpi-value">
<animated-number :value="kpis.readRate" :format="formatPercentage" />
</div>
<div class="kpi-label">Read Rate</div>
<div class="kpi-trend" :class="getTrendClass(kpiTrends.readRate)">
<el-icon><component :is="getTrendIcon(kpiTrends.readRate)" /></el-icon>
{{ Math.abs(kpiTrends.readRate).toFixed(1) }}%
</div>
</div>
<div class="kpi-sparkline">
<sparkline :data="sparklineData.readRate" />
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="kpi-card">
<div class="kpi-content">
<div class="kpi-value">
<animated-number :value="kpis.conversions" :format="formatNumber" />
</div>
<div class="kpi-label">Conversions</div>
<div class="kpi-trend" :class="getTrendClass(kpiTrends.conversions)">
<el-icon><component :is="getTrendIcon(kpiTrends.conversions)" /></el-icon>
{{ Math.abs(kpiTrends.conversions).toFixed(1) }}%
</div>
</div>
<div class="kpi-sparkline">
<sparkline :data="sparklineData.conversions" />
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="kpi-card">
<div class="kpi-content">
<div class="kpi-value">
<animated-number :value="kpis.revenue" :format="formatCurrency" />
</div>
<div class="kpi-label">Revenue</div>
<div class="kpi-trend" :class="getTrendClass(kpiTrends.revenue)">
<el-icon><component :is="getTrendIcon(kpiTrends.revenue)" /></el-icon>
{{ Math.abs(kpiTrends.revenue).toFixed(1) }}%
</div>
</div>
<div class="kpi-sparkline">
<sparkline :data="sparklineData.revenue" />
</div>
</el-card>
</el-col>
</el-row>
<!-- Real-time Charts -->
<el-row :gutter="20" class="charts-row">
<el-col :span="16">
<el-card>
<template #header>
<div class="card-header">
<h3>Real-time Message Flow</h3>
<el-radio-group v-model="chartTimeRange" size="small">
<el-radio-button value="minute">Last Hour</el-radio-button>
<el-radio-button value="hour">Last 24 Hours</el-radio-button>
<el-radio-button value="day">Last 30 Days</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="chart-container">
<line-chart
:data="messageFlowData"
:options="messageFlowOptions"
:height="300"
/>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<template #header>
<h3>Conversion Funnel</h3>
</template>
<div class="funnel-container">
<funnel-chart :data="funnelData" />
</div>
</el-card>
</el-col>
</el-row>
<!-- Activity Feed & Heatmap -->
<el-row :gutter="20" class="activity-row">
<el-col :span="12">
<el-card>
<template #header>
<h3>Engagement Heatmap</h3>
</template>
<div class="heatmap-container">
<heatmap-chart :data="heatmapData" />
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<h3>Live Activity Feed</h3>
<el-tag>{{ activityFeed.length }} events</el-tag>
</div>
</template>
<div class="activity-feed">
<transition-group name="feed" tag="div">
<div
v-for="activity in activityFeed"
:key="activity.id"
class="activity-item"
>
<el-icon :class="`activity-icon ${activity.type}`">
<component :is="getActivityIcon(activity.type)" />
</el-icon>
<div class="activity-content">
<div class="activity-message">{{ activity.message }}</div>
<div class="activity-time">{{ formatTime(activity.timestamp) }}</div>
</div>
</div>
</transition-group>
</div>
</el-card>
</el-col>
</el-row>
<!-- Campaign Performance -->
<el-card class="campaign-performance">
<template #header>
<h3>Top Performing Campaigns (Real-time)</h3>
</template>
<el-table :data="topCampaigns" height="300">
<el-table-column prop="name" label="Campaign" />
<el-table-column prop="sent" label="Sent" width="100" />
<el-table-column prop="delivered" label="Delivered" width="100" />
<el-table-column prop="read" label="Read" width="100" />
<el-table-column label="Read Rate" width="120">
<template #default="{ row }">
<el-progress
:percentage="row.readRate"
:color="getProgressColor(row.readRate)"
/>
</template>
</el-table-column>
<el-table-column prop="conversions" label="Conversions" width="120" />
<el-table-column prop="revenue" label="Revenue" width="120">
<template #default="{ row }">
${{ row.revenue.toFixed(2) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { io } from 'socket.io-client'
import api from '@/api'
import LineChart from '@/components/charts/LineChart.vue'
import FunnelChart from '@/components/charts/FunnelChart.vue'
import HeatmapChart from '@/components/charts/HeatmapChart.vue'
import Sparkline from '@/components/charts/Sparkline.vue'
import AnimatedNumber from '@/components/AnimatedNumber.vue'
import {
VideoPlay, VideoPause, TrendCharts, ArrowUp, ArrowDown,
Message, View, ShoppingCart, Money
} from '@element-plus/icons-vue'
const authStore = useAuthStore()
// State
const autoRefresh = ref(true)
const chartTimeRange = ref('hour')
const selectedMetrics = ref(['messages_sent', 'messages_delivered', 'messages_read', 'conversions'])
const socket = ref(null)
const kpis = reactive({
deliveryRate: 0,
readRate: 0,
conversions: 0,
revenue: 0
})
const kpiTrends = reactive({
deliveryRate: 0,
readRate: 0,
conversions: 0,
revenue: 0
})
const sparklineData = reactive({
deliveryRate: [],
readRate: [],
conversions: [],
revenue: []
})
const messageFlowData = ref({
labels: [],
datasets: []
})
const funnelData = ref([])
const heatmapData = ref([])
const activityFeed = ref([])
const topCampaigns = ref([])
// Available metrics
const availableMetrics = [
{ label: 'Messages Sent', value: 'messages_sent' },
{ label: 'Messages Delivered', value: 'messages_delivered' },
{ label: 'Messages Read', value: 'messages_read' },
{ label: 'Messages Failed', value: 'messages_failed' },
{ label: 'Conversions', value: 'conversions' },
{ label: 'Revenue', value: 'revenue' },
{ label: 'Active Users', value: 'active_users' },
{ label: 'New Users', value: 'new_users' }
]
// Chart options
const messageFlowOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Time'
}
},
y: {
display: true,
title: {
display: true,
text: 'Count'
}
}
}
}
// Methods
const connectSocket = () => {
// Connect to analytics service WebSocket
socket.value = io(import.meta.env.VITE_ANALYTICS_WS_URL || 'ws://localhost:3006', {
auth: {
token: authStore.token
}
})
socket.value.on('connect', () => {
console.log('Connected to real-time analytics')
// Subscribe to metrics
socket.value.emit('subscribe-metrics', {
accountId: authStore.user.accountId,
metrics: selectedMetrics.value
})
})
socket.value.on('metric-update', (data) => {
handleMetricUpdate(data)
})
socket.value.on('disconnect', () => {
console.log('Disconnected from real-time analytics')
})
}
const handleMetricUpdate = (data) => {
// Update activity feed
const activity = {
id: Date.now(),
type: data.metricType,
message: getActivityMessage(data),
timestamp: new Date(data.timestamp)
}
activityFeed.value.unshift(activity)
if (activityFeed.value.length > 20) {
activityFeed.value.pop()
}
// Update relevant data based on metric type
updateDashboardData()
}
const getActivityMessage = (data) => {
const messages = {
messages_sent: `Message sent to ${data.metadata?.userId || 'user'}`,
messages_delivered: `Message delivered successfully`,
messages_read: `Message read by user`,
conversions: `New conversion! Value: $${data.value}`,
revenue: `Revenue recorded: $${data.value}`,
new_users: `New user signed up`,
active_users: `User became active`
}
return messages[data.metricType] || `${data.metricType} event`
}
const getActivityIcon = (type) => {
const icons = {
messages_sent: 'Message',
messages_delivered: 'Check',
messages_read: 'View',
conversions: 'ShoppingCart',
revenue: 'Money',
new_users: 'UserFilled',
active_users: 'User'
}
return icons[type] || 'Document'
}
const loadDashboardData = async () => {
try {
const response = await api.get(`/api/v1/analytics/realtime/${authStore.user.accountId}/dashboard`)
// Update KPIs
kpis.deliveryRate = response.data.kpis.deliveryRate
kpis.readRate = response.data.kpis.readRate
kpis.conversions = response.data.realtime.conversions.summary.total
kpis.revenue = response.data.realtime.revenue.summary.sum
// Update trends
kpiTrends.deliveryRate = response.data.kpis.deliveryRate - 85 // Previous period comparison
kpiTrends.readRate = response.data.kpis.readRate - 45
kpiTrends.conversions = response.data.realtime.conversions.summary.trend
kpiTrends.revenue = response.data.realtime.revenue.summary.trend
// Update sparkline data
updateSparklineData(response.data)
// Update message flow chart
updateMessageFlowChart(response.data)
// Load funnel data
loadFunnelData()
// Load heatmap data
loadHeatmapData()
// Load top campaigns
loadTopCampaigns()
} catch (error) {
console.error('Failed to load dashboard data:', error)
}
}
const updateSparklineData = (data) => {
const metrics = ['deliveryRate', 'readRate', 'conversions', 'revenue']
metrics.forEach(metric => {
const metricData = data.hourly[metric === 'deliveryRate' || metric === 'readRate' ?
'messages_delivered' : metric]
if (metricData) {
sparklineData[metric] = metricData.dataPoints
.slice(-20)
.map(dp => dp.count || dp.sum)
}
})
}
const updateMessageFlowChart = (data) => {
const timeData = data[chartTimeRange.value]
messageFlowData.value = {
labels: timeData.messages_sent.dataPoints.map(dp =>
new Date(dp.timestamp).toLocaleTimeString()
),
datasets: [
{
label: 'Sent',
data: timeData.messages_sent.dataPoints.map(dp => dp.count),
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1
},
{
label: 'Delivered',
data: timeData.messages_delivered.dataPoints.map(dp => dp.count),
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.2)',
tension: 0.1
},
{
label: 'Read',
data: timeData.messages_read.dataPoints.map(dp => dp.count),
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
tension: 0.1
}
]
}
}
const loadFunnelData = async () => {
try {
const response = await api.post(
`/api/v1/analytics/realtime/${authStore.user.accountId}/funnel`,
{
steps: [
{ name: 'Messages Sent', metric: 'messages_sent' },
{ name: 'Delivered', metric: 'messages_delivered' },
{ name: 'Read', metric: 'messages_read' },
{ name: 'Converted', metric: 'conversions' }
],
timeRange: chartTimeRange.value
}
)
funnelData.value = response.data.funnel.steps
} catch (error) {
console.error('Failed to load funnel data:', error)
}
}
const loadHeatmapData = async () => {
// Generate sample heatmap data
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const hours = Array.from({ length: 24 }, (_, i) => i)
heatmapData.value = days.map((day, dayIndex) =>
hours.map(hour => ({
day: dayIndex,
hour,
value: Math.random() * 100
}))
).flat()
}
const loadTopCampaigns = async () => {
try {
const response = await api.get(
`/api/v1/analytics/analytics/${authStore.user.accountId}/top-campaigns`
)
topCampaigns.value = response.data.campaigns.map(campaign => ({
name: campaign.campaignName,
sent: campaign.totalMessagesSent,
delivered: campaign.totalMessagesDelivered,
read: campaign.totalMessagesRead,
readRate: Math.round(campaign.readRate),
conversions: campaign.totalConversions,
revenue: campaign.totalRevenue
}))
} catch (error) {
console.error('Failed to load top campaigns:', error)
}
}
const updateDashboardData = () => {
if (autoRefresh.value) {
loadDashboardData()
}
}
const toggleAutoRefresh = () => {
autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) {
updateDashboardData()
}
}
// Helper functions
const formatPercentage = (value) => `${value.toFixed(1)}%`
const formatNumber = (value) => value.toLocaleString()
const formatCurrency = (value) => `$${value.toLocaleString()}`
const formatTime = (date) => new Date(date).toLocaleTimeString()
const getTrendClass = (trend) => trend >= 0 ? 'positive' : 'negative'
const getTrendIcon = (trend) => trend >= 0 ? 'ArrowUp' : 'ArrowDown'
const getProgressColor = (percentage) => {
if (percentage >= 80) return '#67c23a'
if (percentage >= 60) return '#e6a23c'
if (percentage >= 40) return '#f56c6c'
return '#909399'
}
// Auto-refresh interval
let refreshInterval
onMounted(() => {
connectSocket()
loadDashboardData()
refreshInterval = setInterval(() => {
if (autoRefresh.value) {
updateDashboardData()
}
}, 5000)
})
onUnmounted(() => {
if (socket.value) {
socket.value.disconnect()
}
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<style lang="scss" scoped>
.realtime-dashboard {
padding: 20px;
background: #f5f7fa;
min-height: 100vh;
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h1 {
margin: 0;
font-size: 24px;
}
.header-actions {
display: flex;
gap: 16px;
align-items: center;
}
}
.kpi-row {
margin-bottom: 20px;
.kpi-card {
height: 120px;
:deep(.el-card__body) {
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi-content {
flex: 1;
.kpi-value {
font-size: 32px;
font-weight: bold;
line-height: 1;
}
.kpi-label {
color: #909399;
font-size: 14px;
margin-top: 8px;
}
.kpi-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
margin-top: 8px;
&.positive {
color: #67c23a;
}
&.negative {
color: #f56c6c;
}
}
}
.kpi-sparkline {
width: 80px;
height: 40px;
}
}
}
.charts-row {
margin-bottom: 20px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
}
}
.chart-container {
height: 300px;
}
.funnel-container {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
}
.activity-row {
margin-bottom: 20px;
.heatmap-container {
height: 400px;
}
.activity-feed {
height: 400px;
overflow-y: auto;
.activity-item {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
&:last-child {
border-bottom: none;
}
.activity-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
&.messages_sent {
background: #409eff;
}
&.messages_delivered {
background: #67c23a;
}
&.messages_read {
background: #e6a23c;
}
&.conversions {
background: #f56c6c;
}
&.revenue {
background: #909399;
}
}
.activity-content {
flex: 1;
.activity-message {
font-size: 14px;
}
.activity-time {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
}
}
}
.feed-enter-active,
.feed-leave-active {
transition: all 0.3s;
}
.feed-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.feed-leave-to {
opacity: 0;
transform: translateX(30px);
}
}
.campaign-performance {
:deep(.el-table) {
font-size: 14px;
}
}
}
</style>

View File

@@ -0,0 +1,641 @@
<template>
<div class="reports-page">
<div class="page-header">
<h1>Analytics Reports</h1>
<el-button type="primary" @click="showGenerateDialog = true">
<el-icon><DocumentAdd /></el-icon>
Generate Report
</el-button>
</div>
<!-- Quick Reports -->
<el-card class="quick-reports">
<template #header>
<h3>Quick Reports</h3>
</template>
<el-row :gutter="20">
<el-col :span="6" v-for="report in quickReports" :key="report.type">
<div class="quick-report-card" @click="generateQuickReport(report)">
<el-icon :size="32" :color="report.color">
<component :is="report.icon" />
</el-icon>
<h4>{{ report.name }}</h4>
<p>{{ report.description }}</p>
</div>
</el-col>
</el-row>
</el-card>
<!-- Scheduled Reports -->
<el-card class="scheduled-reports">
<template #header>
<div class="card-header">
<h3>Scheduled Reports</h3>
<el-button size="small" @click="showScheduleDialog = true">
<el-icon><Clock /></el-icon>
Schedule Report
</el-button>
</div>
</template>
<el-table :data="scheduledReports" style="width: 100%">
<el-table-column prop="name" label="Report Name" />
<el-table-column prop="type" label="Type" width="150">
<template #default="{ row }">
<el-tag>{{ formatReportType(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="schedule" label="Schedule" width="200" />
<el-table-column prop="recipients" label="Recipients" width="250">
<template #default="{ row }">
<el-tooltip :content="row.recipients.join(', ')">
<span>{{ row.recipients.length }} recipients</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="Status" width="100">
<template #default="{ row }">
<el-switch v-model="row.active" @change="toggleSchedule(row)" />
</template>
</el-table-column>
<el-table-column label="Actions" width="150">
<template #default="{ row }">
<el-button size="small" @click="editSchedule(row)">Edit</el-button>
<el-button size="small" type="danger" plain @click="deleteSchedule(row)">Delete</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Report History -->
<el-card class="report-history">
<template #header>
<div class="card-header">
<h3>Report History</h3>
<el-input
v-model="searchQuery"
placeholder="Search reports..."
suffix-icon="Search"
style="width: 300px"
/>
</div>
</template>
<el-table :data="filteredReportHistory" style="width: 100%">
<el-table-column prop="name" label="Report Name" />
<el-table-column prop="type" label="Type" width="150">
<template #default="{ row }">
<el-tag>{{ formatReportType(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="generatedAt" label="Generated" width="200">
<template #default="{ row }">
{{ formatDate(row.generatedAt) }}
</template>
</el-table-column>
<el-table-column prop="format" label="Format" width="100">
<template #default="{ row }">
<el-tag :type="getFormatType(row.format)">{{ row.format.toUpperCase() }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="size" label="Size" width="100">
<template #default="{ row }">
{{ formatFileSize(row.size) }}
</template>
</el-table-column>
<el-table-column label="Actions" width="200">
<template #default="{ row }">
<el-button size="small" @click="downloadReport(row)">
<el-icon><Download /></el-icon>
Download
</el-button>
<el-button size="small" plain @click="viewReport(row)">
<el-icon><View /></el-icon>
View
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Generate Report Dialog -->
<el-dialog
v-model="showGenerateDialog"
title="Generate Report"
width="600px"
>
<el-form :model="reportForm" label-width="120px">
<el-form-item label="Report Type">
<el-select v-model="reportForm.type" placeholder="Select report type">
<el-option
v-for="type in reportTypes"
:key="type.value"
:label="type.label"
:value="type.value"
/>
</el-select>
</el-form-item>
<el-form-item label="Date Range">
<el-date-picker
v-model="reportForm.dateRange"
type="daterange"
range-separator="to"
start-placeholder="Start date"
end-placeholder="End date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="Format">
<el-radio-group v-model="reportForm.format">
<el-radio value="pdf">PDF</el-radio>
<el-radio value="excel">Excel</el-radio>
<el-radio value="json">JSON</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="Options">
<el-checkbox v-model="reportForm.includeCharts">Include Charts</el-checkbox>
<el-checkbox v-model="reportForm.includeRawData">Include Raw Data</el-checkbox>
<el-checkbox v-model="reportForm.comparePeriods">Compare with Previous Period</el-checkbox>
</el-form-item>
<el-form-item label="Recipients" v-if="reportForm.sendEmail">
<el-input
v-model="reportForm.recipients"
placeholder="Enter email addresses separated by commas"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="reportForm.sendEmail">Send report via email</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showGenerateDialog = false">Cancel</el-button>
<el-button type="primary" @click="generateReport" :loading="generating">
Generate
</el-button>
</template>
</el-dialog>
<!-- Schedule Report Dialog -->
<el-dialog
v-model="showScheduleDialog"
title="Schedule Report"
width="600px"
>
<el-form :model="scheduleForm" label-width="120px">
<el-form-item label="Report Name">
<el-input v-model="scheduleForm.name" placeholder="Enter report name" />
</el-form-item>
<el-form-item label="Report Type">
<el-select v-model="scheduleForm.type" placeholder="Select report type">
<el-option
v-for="type in reportTypes"
:key="type.value"
:label="type.label"
:value="type.value"
/>
</el-select>
</el-form-item>
<el-form-item label="Schedule">
<el-select v-model="scheduleForm.frequency">
<el-option value="daily" label="Daily" />
<el-option value="weekly" label="Weekly" />
<el-option value="monthly" label="Monthly" />
</el-select>
</el-form-item>
<el-form-item label="Time" v-if="scheduleForm.frequency">
<el-time-picker
v-model="scheduleForm.time"
format="HH:mm"
value-format="HH:mm"
/>
</el-form-item>
<el-form-item label="Recipients">
<el-input
v-model="scheduleForm.recipients"
placeholder="Enter email addresses separated by commas"
type="textarea"
:rows="3"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showScheduleDialog = false">Cancel</el-button>
<el-button type="primary" @click="saveSchedule">
Save Schedule
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import api from '@/api'
import {
DocumentAdd, Clock, Download, View, Search,
TrendCharts, User, ShoppingCart, Message
} from '@element-plus/icons-vue'
const authStore = useAuthStore()
const showGenerateDialog = ref(false)
const showScheduleDialog = ref(false)
const generating = ref(false)
const searchQuery = ref('')
const reportForm = reactive({
type: '',
dateRange: [],
format: 'pdf',
includeCharts: true,
includeRawData: false,
comparePeriods: false,
sendEmail: false,
recipients: ''
})
const scheduleForm = reactive({
name: '',
type: '',
frequency: 'weekly',
time: '09:00',
recipients: ''
})
const quickReports = [
{
type: 'campaign_performance',
name: 'Campaign Performance',
description: 'Last 7 days campaign metrics',
icon: 'TrendCharts',
color: '#409eff'
},
{
type: 'user_engagement',
name: 'User Engagement',
description: 'User activity and engagement',
icon: 'User',
color: '#67c23a'
},
{
type: 'conversion_report',
name: 'Conversion Report',
description: 'Conversion funnel analysis',
icon: 'ShoppingCart',
color: '#e6a23c'
},
{
type: 'message_analytics',
name: 'Message Analytics',
description: 'Message delivery and read rates',
icon: 'Message',
color: '#f56c6c'
}
]
const reportTypes = [
{ label: 'Campaign Performance', value: 'campaign_performance' },
{ label: 'User Engagement', value: 'user_engagement' },
{ label: 'Conversion Report', value: 'conversion_report' },
{ label: 'Message Analytics', value: 'message_analytics' },
{ label: 'Revenue Report', value: 'revenue_report' },
{ label: 'A/B Test Results', value: 'ab_test_results' },
{ label: 'Cohort Analysis', value: 'cohort_analysis' },
{ label: 'Custom Report', value: 'custom' }
]
const scheduledReports = ref([
{
id: 1,
name: 'Weekly Campaign Report',
type: 'campaign_performance',
schedule: 'Every Monday at 9:00 AM',
recipients: ['team@example.com', 'manager@example.com'],
active: true
},
{
id: 2,
name: 'Monthly Revenue Report',
type: 'revenue_report',
schedule: '1st of every month at 8:00 AM',
recipients: ['cfo@example.com'],
active: true
}
])
const reportHistory = ref([
{
id: 1,
name: 'Campaign Performance - Week 45',
type: 'campaign_performance',
generatedAt: new Date('2024-11-10T10:30:00'),
format: 'pdf',
size: 2456789,
url: '/reports/campaign_week45.pdf'
},
{
id: 2,
name: 'User Engagement Analysis',
type: 'user_engagement',
generatedAt: new Date('2024-11-09T14:20:00'),
format: 'excel',
size: 1234567,
url: '/reports/user_engagement.xlsx'
}
])
const filteredReportHistory = computed(() => {
if (!searchQuery.value) return reportHistory.value
const query = searchQuery.value.toLowerCase()
return reportHistory.value.filter(report =>
report.name.toLowerCase().includes(query) ||
report.type.toLowerCase().includes(query)
)
})
const generateQuickReport = async (report) => {
generating.value = true
try {
const endDate = new Date()
const startDate = new Date()
startDate.setDate(startDate.getDate() - 7)
const response = await api.post('/api/v1/analytics/reports/generate', {
accountId: authStore.user.accountId,
reportType: report.type,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
format: 'pdf',
includeCharts: true
})
// Download the report
window.open(response.data.downloadUrl, '_blank')
ElMessage.success('Report generated successfully!')
// Add to history
reportHistory.value.unshift({
id: Date.now(),
name: `${report.name} - ${new Date().toLocaleDateString()}`,
type: report.type,
generatedAt: new Date(),
format: 'pdf',
size: response.data.size || 0,
url: response.data.downloadUrl
})
} catch (error) {
ElMessage.error('Failed to generate report')
} finally {
generating.value = false
}
}
const generateReport = async () => {
if (!reportForm.type || !reportForm.dateRange || reportForm.dateRange.length !== 2) {
ElMessage.warning('Please fill in all required fields')
return
}
generating.value = true
try {
const response = await api.post('/api/v1/analytics/reports/generate', {
accountId: authStore.user.accountId,
reportType: reportForm.type,
startDate: reportForm.dateRange[0],
endDate: reportForm.dateRange[1],
format: reportForm.format,
includeCharts: reportForm.includeCharts,
includeRawData: reportForm.includeRawData,
comparePeriods: reportForm.comparePeriods
})
if (reportForm.sendEmail) {
// Send email with report
const recipients = reportForm.recipients.split(',').map(email => email.trim())
await api.post('/api/v1/analytics/reports/send', {
reportId: response.data.reportId,
recipients
})
ElMessage.success('Report generated and sent via email!')
} else {
// Download the report
window.open(response.data.downloadUrl, '_blank')
ElMessage.success('Report generated successfully!')
}
showGenerateDialog.value = false
// Add to history
reportHistory.value.unshift({
id: response.data.reportId,
name: `${formatReportType(reportForm.type)} - ${new Date().toLocaleDateString()}`,
type: reportForm.type,
generatedAt: new Date(),
format: reportForm.format,
size: response.data.size || 0,
url: response.data.downloadUrl
})
} catch (error) {
ElMessage.error('Failed to generate report')
} finally {
generating.value = false
}
}
const saveSchedule = () => {
if (!scheduleForm.name || !scheduleForm.type || !scheduleForm.recipients) {
ElMessage.warning('Please fill in all required fields')
return
}
const recipients = scheduleForm.recipients.split(',').map(email => email.trim())
scheduledReports.value.push({
id: Date.now(),
name: scheduleForm.name,
type: scheduleForm.type,
schedule: formatSchedule(scheduleForm.frequency, scheduleForm.time),
recipients,
active: true
})
ElMessage.success('Report scheduled successfully!')
showScheduleDialog.value = false
// Reset form
Object.assign(scheduleForm, {
name: '',
type: '',
frequency: 'weekly',
time: '09:00',
recipients: ''
})
}
const toggleSchedule = (schedule) => {
ElMessage.success(`Schedule ${schedule.active ? 'activated' : 'deactivated'}`)
}
const editSchedule = (schedule) => {
// TODO: Implement edit functionality
ElMessage.info('Edit functionality coming soon')
}
const deleteSchedule = async (schedule) => {
await ElMessageBox.confirm(
'Are you sure you want to delete this scheduled report?',
'Delete Schedule',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
const index = scheduledReports.value.findIndex(s => s.id === schedule.id)
if (index > -1) {
scheduledReports.value.splice(index, 1)
ElMessage.success('Schedule deleted successfully')
}
}
const downloadReport = (report) => {
window.open(report.url, '_blank')
}
const viewReport = (report) => {
if (report.format === 'json') {
// TODO: Open JSON viewer
ElMessage.info('JSON viewer coming soon')
} else {
window.open(report.url, '_blank')
}
}
// Helper functions
const formatReportType = (type) => {
const typeMap = {
campaign_performance: 'Campaign',
user_engagement: 'Engagement',
conversion_report: 'Conversion',
message_analytics: 'Messages',
revenue_report: 'Revenue',
ab_test_results: 'A/B Test',
cohort_analysis: 'Cohort',
custom: 'Custom'
}
return typeMap[type] || type
}
const formatDate = (date) => {
return new Date(date).toLocaleString()
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const getFormatType = (format) => {
const types = {
pdf: 'danger',
excel: 'success',
json: 'info'
}
return types[format] || ''
}
const formatSchedule = (frequency, time) => {
const schedules = {
daily: `Every day at ${time}`,
weekly: `Every Monday at ${time}`,
monthly: `1st of every month at ${time}`
}
return schedules[frequency] || frequency
}
</script>
<style lang="scss" scoped>
.reports-page {
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h1 {
margin: 0;
font-size: 24px;
}
}
.quick-reports {
margin-bottom: 20px;
.quick-report-card {
background: #f5f7fa;
border-radius: 8px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
height: 160px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&:hover {
background: #ecf5ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
h4 {
margin: 12px 0 8px;
font-size: 16px;
}
p {
margin: 0;
color: #909399;
font-size: 14px;
}
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
}
}
.scheduled-reports,
.report-history {
margin-bottom: 20px;
}
}
</style>

View File

@@ -0,0 +1,482 @@
<template>
<div class="billing-dashboard">
<h1>计费和支付</h1>
<!-- Current Plan Section -->
<div class="current-plan-section">
<h2>当前套餐</h2>
<el-card v-if="currentSubscription" class="plan-card">
<div class="plan-header">
<h3>{{ currentSubscription.planName }}</h3>
<el-tag :type="getStatusType(currentSubscription.status)">
{{ getStatusText(currentSubscription.status) }}
</el-tag>
</div>
<div class="plan-details">
<p><strong>价格</strong>{{ formatCurrency(currentSubscription.planPrice) }}/{{ getBillingCycleText(currentSubscription.billingCycle) }}</p>
<p><strong>下次账单日期</strong>{{ formatDate(currentSubscription.currentPeriodEnd) }}</p>
<p v-if="currentSubscription.trialEnd">
<strong>试用期结束</strong>{{ formatDate(currentSubscription.trialEnd) }}
</p>
</div>
<div class="plan-usage">
<h4>使用情况</h4>
<el-progress
:percentage="getUsagePercentage('messages')"
:format="format => `消息: ${currentUsage.messages}/${planLimits.messages}`"
/>
<el-progress
:percentage="getUsagePercentage('campaigns')"
:format="format => `活动: ${currentUsage.campaigns}/${planLimits.campaigns}`"
/>
<el-progress
:percentage="getUsagePercentage('users')"
:format="format => `用户: ${currentUsage.users}/${planLimits.users}`"
/>
</div>
<div class="plan-actions">
<el-button type="primary" @click="showUpgradeDialog = true">升级套餐</el-button>
<el-button @click="showCancelDialog = true">取消订阅</el-button>
</div>
</el-card>
<el-card v-else class="plan-card">
<p>您目前没有活跃的订阅</p>
<el-button type="primary" @click="showUpgradeDialog = true">选择套餐</el-button>
</el-card>
</div>
<!-- Payment Methods Section -->
<div class="payment-methods-section">
<h2>支付方式</h2>
<el-card>
<div class="methods-header">
<el-button type="primary" size="small" @click="showAddPaymentDialog = true">
<el-icon><Plus /></el-icon> 添加支付方式
</el-button>
</div>
<el-table :data="paymentMethods" v-loading="loadingPaymentMethods">
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-icon v-if="row.type === 'card'"><CreditCard /></el-icon>
<el-icon v-else><Bank /></el-icon>
{{ row.type === 'card' ? '信用卡' : '银行账户' }}
</template>
</el-table-column>
<el-table-column label="详情">
<template #default="{ row }">
<span v-if="row.type === 'card'">
{{ row.card.brand }} **** {{ row.card.last4 }}
({{ row.card.expMonth }}/{{ row.card.expYear }})
</span>
<span v-else>
{{ row.bankAccount.bankName }} **** {{ row.bankAccount.last4 }}
</span>
</template>
</el-table-column>
<el-table-column label="默认" width="80">
<template #default="{ row }">
<el-tag v-if="row.isDefault" type="success">默认</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button
v-if="!row.isDefault"
size="small"
@click="setDefaultPaymentMethod(row.id)"
>
设为默认
</el-button>
<el-button
size="small"
type="danger"
@click="removePaymentMethod(row.id)"
:disabled="row.isDefault"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- Invoices Section -->
<div class="invoices-section">
<h2>账单历史</h2>
<el-card>
<el-table :data="invoices" v-loading="loadingInvoices">
<el-table-column prop="invoiceNumber" label="账单号" width="150" />
<el-table-column label="日期" width="120">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="金额" width="100">
<template #default="{ row }">
{{ formatCurrency(row.total) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getInvoiceStatusType(row.status)">
{{ getInvoiceStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button size="small" @click="downloadInvoice(row.id)">
下载 PDF
</el-button>
<el-button
v-if="row.status === 'open'"
size="small"
type="primary"
@click="payInvoice(row.id)"
>
立即支付
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="invoicePage"
v-model:page-size="invoicePageSize"
:total="invoiceTotal"
layout="total, prev, pager, next"
@current-change="loadInvoices"
/>
</el-card>
</div>
<!-- Upgrade Dialog -->
<el-dialog v-model="showUpgradeDialog" title="选择套餐" width="800px">
<SubscriptionUpgrade
:current-plan="currentSubscription?.plan"
@upgrade="handleUpgrade"
@cancel="showUpgradeDialog = false"
/>
</el-dialog>
<!-- Add Payment Method Dialog -->
<el-dialog v-model="showAddPaymentDialog" title="添加支付方式" width="500px">
<AddPaymentMethod
@success="handlePaymentMethodAdded"
@cancel="showAddPaymentDialog = false"
/>
</el-dialog>
<!-- Cancel Subscription Dialog -->
<el-dialog v-model="showCancelDialog" title="取消订阅" width="500px">
<p>您确定要取消当前订阅吗</p>
<p>您的订阅将在当前计费周期结束时停止</p>
<el-form @submit.prevent="cancelSubscription">
<el-form-item label="取消原因">
<el-input
v-model="cancelReason"
type="textarea"
rows="3"
placeholder="请告诉我们您取消的原因(可选)"
/>
</el-form-item>
<el-form-item>
<el-button @click="showCancelDialog = false">取消</el-button>
<el-button type="danger" @click="cancelSubscription">确认取消</el-button>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { Plus, CreditCard, Bank } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getSubscriptions,
getPaymentMethods,
getInvoices,
setDefaultPaymentMethod as apiSetDefaultPaymentMethod,
removePaymentMethod as apiRemovePaymentMethod,
cancelSubscription as apiCancelSubscription,
downloadInvoice as apiDownloadInvoice
} from '@/api/billing'
import SubscriptionUpgrade from './components/SubscriptionUpgrade.vue'
import AddPaymentMethod from './components/AddPaymentMethod.vue'
// State
const currentSubscription = ref(null)
const paymentMethods = ref([])
const invoices = ref([])
const currentUsage = ref({
messages: 0,
campaigns: 0,
users: 0
})
const planLimits = ref({
messages: 1000,
campaigns: 10,
users: 5
})
// Loading states
const loadingPaymentMethods = ref(false)
const loadingInvoices = ref(false)
// Pagination
const invoicePage = ref(1)
const invoicePageSize = ref(10)
const invoiceTotal = ref(0)
// Dialogs
const showUpgradeDialog = ref(false)
const showAddPaymentDialog = ref(false)
const showCancelDialog = ref(false)
const cancelReason = ref('')
// Computed
const getUsagePercentage = (metric) => {
const usage = currentUsage.value[metric]
const limit = planLimits.value[metric]
if (limit === 'unlimited') return 0
return Math.min(100, (usage / limit) * 100)
}
// Methods
const loadSubscriptions = async () => {
try {
const { data } = await getSubscriptions()
const active = data.subscriptions.find(s => s.status === 'active' || s.status === 'trialing')
currentSubscription.value = active
// Update plan limits based on subscription
if (active) {
// This would come from the subscription data
planLimits.value = {
messages: active.metadata?.limits?.messages || 1000,
campaigns: active.metadata?.limits?.campaigns || 10,
users: active.metadata?.limits?.users || 5
}
}
} catch (error) {
ElMessage.error('加载订阅信息失败')
}
}
const loadPaymentMethods = async () => {
loadingPaymentMethods.value = true
try {
const { data } = await getPaymentMethods()
paymentMethods.value = data.paymentMethods
} catch (error) {
ElMessage.error('加载支付方式失败')
} finally {
loadingPaymentMethods.value = false
}
}
const loadInvoices = async () => {
loadingInvoices.value = true
try {
const { data } = await getInvoices({
limit: invoicePageSize.value,
offset: (invoicePage.value - 1) * invoicePageSize.value
})
invoices.value = data.invoices
invoiceTotal.value = data.total
} catch (error) {
ElMessage.error('加载账单失败')
} finally {
loadingInvoices.value = false
}
}
const setDefaultPaymentMethod = async (id) => {
try {
await apiSetDefaultPaymentMethod(id)
ElMessage.success('默认支付方式已更新')
await loadPaymentMethods()
} catch (error) {
ElMessage.error('设置默认支付方式失败')
}
}
const removePaymentMethod = async (id) => {
try {
await ElMessageBox.confirm('确定要删除此支付方式吗?', '确认删除')
await apiRemovePaymentMethod(id)
ElMessage.success('支付方式已删除')
await loadPaymentMethods()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除支付方式失败')
}
}
}
const cancelSubscription = async () => {
try {
await apiCancelSubscription(currentSubscription.value.id, {
reason: cancelReason.value
})
ElMessage.success('订阅已取消')
showCancelDialog.value = false
await loadSubscriptions()
} catch (error) {
ElMessage.error('取消订阅失败')
}
}
const downloadInvoice = async (id) => {
try {
const response = await apiDownloadInvoice(id)
// Handle file download
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `invoice-${id}.pdf`)
document.body.appendChild(link)
link.click()
} catch (error) {
ElMessage.error('下载账单失败')
}
}
const payInvoice = async (id) => {
// Navigate to payment page or open payment modal
ElMessage.info('支付功能开发中')
}
const handleUpgrade = async (plan) => {
showUpgradeDialog.value = false
await loadSubscriptions()
ElMessage.success('套餐升级成功')
}
const handlePaymentMethodAdded = async () => {
showAddPaymentDialog.value = false
await loadPaymentMethods()
ElMessage.success('支付方式添加成功')
}
// Formatting helpers
const formatCurrency = (amount) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'USD'
}).format(amount)
}
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
const getBillingCycleText = (cycle) => {
const cycles = {
monthly: '月',
quarterly: '季度',
yearly: '年'
}
return cycles[cycle] || cycle
}
const getStatusType = (status) => {
const types = {
active: 'success',
trialing: 'warning',
past_due: 'danger',
canceled: 'info'
}
return types[status] || 'info'
}
const getStatusText = (status) => {
const texts = {
active: '活跃',
trialing: '试用中',
past_due: '逾期',
canceled: '已取消'
}
return texts[status] || status
}
const getInvoiceStatusType = (status) => {
const types = {
draft: 'info',
open: 'warning',
paid: 'success',
void: 'danger'
}
return types[status] || 'info'
}
const getInvoiceStatusText = (status) => {
const texts = {
draft: '草稿',
open: '待支付',
paid: '已支付',
void: '已作废'
}
return texts[status] || status
}
// Lifecycle
onMounted(() => {
loadSubscriptions()
loadPaymentMethods()
loadInvoices()
})
</script>
<style scoped>
.billing-dashboard {
padding: 20px;
}
.current-plan-section,
.payment-methods-section,
.invoices-section {
margin-bottom: 30px;
}
.plan-card {
max-width: 600px;
}
.plan-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.plan-details {
margin-bottom: 20px;
}
.plan-details p {
margin: 5px 0;
}
.plan-usage {
margin-bottom: 20px;
}
.plan-usage h4 {
margin-bottom: 10px;
}
.plan-usage .el-progress {
margin-bottom: 10px;
}
.plan-actions {
display: flex;
gap: 10px;
}
.methods-header {
margin-bottom: 15px;
}
</style>

View File

@@ -0,0 +1,393 @@
<template>
<div class="add-payment-method">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
>
<el-form-item label="支付方式类型" prop="type">
<el-radio-group v-model="formData.type">
<el-radio label="card">信用卡/借记卡</el-radio>
<el-radio label="bank_account">银行账户</el-radio>
</el-radio-group>
</el-form-item>
<!-- Credit Card Form -->
<template v-if="formData.type === 'card'">
<el-form-item label="卡号" prop="cardNumber">
<el-input
v-model="formData.cardNumber"
placeholder="1234 5678 9012 3456"
maxlength="19"
@input="formatCardNumber"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="有效期" prop="expiry">
<el-input
v-model="formData.expiry"
placeholder="MM/YY"
maxlength="5"
@input="formatExpiry"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="CVV" prop="cvv">
<el-input
v-model="formData.cvv"
placeholder="123"
maxlength="4"
type="password"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="持卡人姓名" prop="cardholderName">
<el-input
v-model="formData.cardholderName"
placeholder="如卡片所示"
/>
</el-form-item>
</template>
<!-- Bank Account Form -->
<template v-if="formData.type === 'bank_account'">
<el-form-item label="账户类型" prop="accountType">
<el-radio-group v-model="formData.accountType">
<el-radio label="checking">支票账户</el-radio>
<el-radio label="savings">储蓄账户</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="路由号码" prop="routingNumber">
<el-input
v-model="formData.routingNumber"
placeholder="123456789"
maxlength="9"
/>
</el-form-item>
<el-form-item label="账户号码" prop="accountNumber">
<el-input
v-model="formData.accountNumber"
placeholder="1234567890"
type="password"
/>
</el-form-item>
<el-form-item label="确认账户号码" prop="confirmAccountNumber">
<el-input
v-model="formData.confirmAccountNumber"
placeholder="再次输入账户号码"
type="password"
/>
</el-form-item>
<el-form-item label="账户持有人姓名" prop="accountHolderName">
<el-input
v-model="formData.accountHolderName"
placeholder="如银行记录所示"
/>
</el-form-item>
</template>
<!-- Billing Address -->
<el-divider>账单地址</el-divider>
<el-form-item label="国家/地区" prop="country">
<el-select v-model="formData.country" placeholder="选择国家/地区">
<el-option label="中国" value="CN" />
<el-option label="美国" value="US" />
<el-option label="英国" value="GB" />
<el-option label="日本" value="JP" />
<!-- Add more countries -->
</el-select>
</el-form-item>
<el-form-item label="地址行1" prop="addressLine1">
<el-input
v-model="formData.addressLine1"
placeholder="街道地址"
/>
</el-form-item>
<el-form-item label="地址行2">
<el-input
v-model="formData.addressLine2"
placeholder="公寓、套房等(可选)"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="城市" prop="city">
<el-input v-model="formData.city" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="州/省" prop="state">
<el-input v-model="formData.state" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="邮政编码" prop="postalCode">
<el-input v-model="formData.postalCode" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="formData.setAsDefault">
设为默认支付方式
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button @click="handleCancel">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="loading"
>
添加支付方式
</el-button>
</el-form-item>
</el-form>
<!-- Security Notice -->
<div class="security-notice">
<el-icon><Lock /></el-icon>
<span>您的支付信息通过安全加密传输并由 Stripe 处理我们不会存储您的完整卡号或银行账户信息</span>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { Lock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { addPaymentMethod } from '@/api/billing'
const emit = defineEmits(['success', 'cancel'])
// Form data
const formRef = ref()
const loading = ref(false)
const formData = reactive({
type: 'card',
// Card fields
cardNumber: '',
expiry: '',
cvv: '',
cardholderName: '',
// Bank account fields
accountType: 'checking',
routingNumber: '',
accountNumber: '',
confirmAccountNumber: '',
accountHolderName: '',
// Billing address
country: 'CN',
addressLine1: '',
addressLine2: '',
city: '',
state: '',
postalCode: '',
// Settings
setAsDefault: true
})
// Validation rules
const rules = {
type: [
{ required: true, message: '请选择支付方式类型', trigger: 'change' }
],
cardNumber: [
{ required: true, message: '请输入卡号', trigger: 'blur' },
{
validator: (rule, value, callback) => {
const cleaned = value.replace(/\s/g, '')
if (!/^\d{13,19}$/.test(cleaned)) {
callback(new Error('请输入有效的卡号'))
} else {
callback()
}
},
trigger: 'blur'
}
],
expiry: [
{ required: true, message: '请输入有效期', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (!/^\d{2}\/\d{2}$/.test(value)) {
callback(new Error('请输入MM/YY格式的有效期'))
} else {
const [month, year] = value.split('/')
const currentYear = new Date().getFullYear() % 100
if (parseInt(month) < 1 || parseInt(month) > 12) {
callback(new Error('无效的月份'))
} else if (parseInt(year) < currentYear) {
callback(new Error('卡片已过期'))
} else {
callback()
}
}
},
trigger: 'blur'
}
],
cvv: [
{ required: true, message: '请输入CVV', trigger: 'blur' },
{ min: 3, max: 4, message: 'CVV必须是3-4位数字', trigger: 'blur' }
],
cardholderName: [
{ required: true, message: '请输入持卡人姓名', trigger: 'blur' }
],
routingNumber: [
{ required: true, message: '请输入路由号码', trigger: 'blur' },
{ len: 9, message: '路由号码必须是9位数字', trigger: 'blur' }
],
accountNumber: [
{ required: true, message: '请输入账户号码', trigger: 'blur' }
],
confirmAccountNumber: [
{ required: true, message: '请确认账户号码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== formData.accountNumber) {
callback(new Error('账户号码不匹配'))
} else {
callback()
}
},
trigger: 'blur'
}
],
accountHolderName: [
{ required: true, message: '请输入账户持有人姓名', trigger: 'blur' }
],
country: [
{ required: true, message: '请选择国家/地区', trigger: 'change' }
],
addressLine1: [
{ required: true, message: '请输入地址', trigger: 'blur' }
],
city: [
{ required: true, message: '请输入城市', trigger: 'blur' }
],
state: [
{ required: true, message: '请输入州/省', trigger: 'blur' }
],
postalCode: [
{ required: true, message: '请输入邮政编码', trigger: 'blur' }
]
}
// Methods
const formatCardNumber = (value) => {
// Remove non-digits
let cleaned = formData.cardNumber.replace(/\D/g, '')
// Add spaces every 4 digits
let formatted = cleaned.replace(/(\d{4})(?=\d)/g, '$1 ')
formData.cardNumber = formatted
}
const formatExpiry = (value) => {
// Remove non-digits
let cleaned = formData.expiry.replace(/\D/g, '')
// Add slash after 2 digits
if (cleaned.length >= 2) {
cleaned = cleaned.slice(0, 2) + '/' + cleaned.slice(2, 4)
}
formData.expiry = cleaned
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
loading.value = true
const payload = {
type: formData.type,
setAsDefault: formData.setAsDefault,
billingAddress: {
line1: formData.addressLine1,
line2: formData.addressLine2,
city: formData.city,
state: formData.state,
postal_code: formData.postalCode,
country: formData.country
}
}
if (formData.type === 'card') {
// In production, you would use Stripe.js to tokenize the card
// For now, we'll send dummy data
payload.token = 'tok_visa' // Stripe test token
payload.card = {
brand: 'Visa',
last4: formData.cardNumber.slice(-4),
expMonth: parseInt(formData.expiry.split('/')[0]),
expYear: 2000 + parseInt(formData.expiry.split('/')[1])
}
} else {
// Bank account data
payload.bankAccount = {
accountType: formData.accountType,
last4: formData.accountNumber.slice(-4),
bankName: 'Bank' // Would be determined from routing number
}
}
await addPaymentMethod(payload)
ElMessage.success('支付方式添加成功')
emit('success')
} catch (error) {
if (error.errors) {
// Validation errors
return
}
ElMessage.error('添加支付方式失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('cancel')
}
</script>
<style scoped>
.add-payment-method {
padding: 20px;
}
.security-notice {
margin-top: 20px;
padding: 12px;
background: #f0f9ff;
border: 1px solid #b3e0ff;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
color: #0066cc;
font-size: 14px;
}
.el-divider {
margin: 30px 0 20px;
}
</style>

View File

@@ -0,0 +1,466 @@
<template>
<div class="subscription-upgrade">
<el-row :gutter="20">
<el-col
v-for="plan in plans"
:key="plan.id"
:span="6"
>
<el-card
:class="['plan-option', {
'current': plan.id === currentPlan,
'recommended': plan.recommended
}]"
>
<div v-if="plan.recommended" class="recommended-badge">
推荐
</div>
<h3>{{ plan.name }}</h3>
<div class="price">
<span class="amount">{{ formatCurrency(plan.price) }}</span>
<span class="period">/</span>
</div>
<div class="price-yearly" v-if="billingCycle === 'yearly'">
<span class="amount">{{ formatCurrency(plan.price * 10) }}</span>
<span class="period">/ (优惠2个月)</span>
</div>
<ul class="features">
<li v-for="feature in plan.features" :key="feature">
<el-icon color="#67C23A"><Check /></el-icon>
{{ feature }}
</li>
</ul>
<el-button
:type="plan.id === currentPlan ? 'info' : 'primary'"
:disabled="plan.id === currentPlan"
@click="selectPlan(plan)"
block
>
{{ plan.id === currentPlan ? '当前套餐' : '选择此套餐' }}
</el-button>
</el-card>
</el-col>
</el-row>
<div class="billing-cycle-toggle">
<el-radio-group v-model="billingCycle">
<el-radio-button label="monthly">按月付费</el-radio-button>
<el-radio-button label="yearly">按年付费 (优惠17%)</el-radio-button>
</el-radio-group>
</div>
<!-- Feature Comparison Table -->
<div class="feature-comparison">
<h3>功能对比</h3>
<el-table :data="featureComparison" stripe>
<el-table-column prop="feature" label="功能" width="200" />
<el-table-column
v-for="plan in plans"
:key="plan.id"
:label="plan.name"
align="center"
>
<template #default="{ row }">
<el-icon v-if="row[plan.id] === true" color="#67C23A">
<Check />
</el-icon>
<el-icon v-else-if="row[plan.id] === false" color="#F56C6C">
<Close />
</el-icon>
<span v-else>{{ row[plan.id] }}</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- Confirmation Dialog -->
<el-dialog
v-model="showConfirmation"
title="确认升级"
width="500px"
>
<div v-if="selectedPlan">
<p>您即将升级到 <strong>{{ selectedPlan.name }}</strong> 套餐</p>
<p>价格{{ formatCurrency(calculatePrice(selectedPlan)) }}/{{ getBillingCycleText() }}</p>
<p v-if="prorationAmount > 0">
按比例计算的金额{{ formatCurrency(prorationAmount) }}
</p>
<el-form @submit.prevent="confirmUpgrade">
<el-form-item label="支付方式">
<el-select v-model="selectedPaymentMethod" placeholder="选择支付方式">
<el-option
v-for="method in paymentMethods"
:key="method.id"
:label="getPaymentMethodLabel(method)"
:value="method.id"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="showConfirmation = false">取消</el-button>
<el-button
type="primary"
@click="confirmUpgrade"
:loading="upgrading"
>
确认升级
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Check, Close } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
createSubscription,
updateSubscription,
getPaymentMethods
} from '@/api/billing'
const props = defineProps({
currentPlan: {
type: String,
default: 'free'
}
})
const emit = defineEmits(['upgrade', 'cancel'])
// State
const billingCycle = ref('monthly')
const selectedPlan = ref(null)
const showConfirmation = ref(false)
const selectedPaymentMethod = ref('')
const paymentMethods = ref([])
const upgrading = ref(false)
const prorationAmount = ref(0)
// Plans data
const plans = ref([
{
id: 'free',
name: '免费版',
price: 0,
features: [
'5个用户',
'10个营销活动',
'每月1,000条消息',
'基础分析',
'社区支持'
]
},
{
id: 'starter',
name: '入门版',
price: 29,
recommended: true,
features: [
'20个用户',
'50个营销活动',
'每月10,000条消息',
'高级分析',
'A/B测试',
'邮件支持'
]
},
{
id: 'professional',
name: '专业版',
price: 99,
features: [
'100个用户',
'无限营销活动',
'每月100,000条消息',
'完整分析套件',
'高级A/B测试',
'AI营销建议',
'优先支持'
]
},
{
id: 'enterprise',
name: '企业版',
price: 299,
features: [
'无限用户',
'无限营销活动',
'无限消息',
'自定义分析',
'专属客户经理',
'SLA保证',
'24/7电话支持'
]
}
])
// Feature comparison data
const featureComparison = ref([
{
feature: '用户数量',
free: '5',
starter: '20',
professional: '100',
enterprise: '无限'
},
{
feature: '营销活动',
free: '10',
starter: '50',
professional: '无限',
enterprise: '无限'
},
{
feature: '每月消息数',
free: '1,000',
starter: '10,000',
professional: '100,000',
enterprise: '无限'
},
{
feature: 'A/B测试',
free: false,
starter: true,
professional: true,
enterprise: true
},
{
feature: 'AI营销建议',
free: false,
starter: false,
professional: true,
enterprise: true
},
{
feature: '自动化工作流',
free: false,
starter: '基础',
professional: '高级',
enterprise: '企业级'
},
{
feature: 'API访问',
free: false,
starter: true,
professional: true,
enterprise: true
},
{
feature: '自定义集成',
free: false,
starter: false,
professional: true,
enterprise: true
},
{
feature: '数据导出',
free: 'CSV',
starter: 'CSV/Excel',
professional: '全部格式',
enterprise: '全部格式+API'
},
{
feature: '技术支持',
free: '社区',
starter: '邮件',
professional: '优先邮件',
enterprise: '24/7专属'
}
])
// Methods
const selectPlan = (plan) => {
if (plan.id === props.currentPlan) return
selectedPlan.value = plan
showConfirmation.value = true
calculateProration()
}
const calculatePrice = (plan) => {
if (billingCycle.value === 'yearly') {
return plan.price * 10 // 2 months free
}
return plan.price
}
const calculateProration = () => {
// In a real app, this would calculate the prorated amount
// based on the remaining time in the current billing period
prorationAmount.value = 0
}
const confirmUpgrade = async () => {
if (!selectedPaymentMethod.value && selectedPlan.value.price > 0) {
ElMessage.error('请选择支付方式')
return
}
upgrading.value = true
try {
if (props.currentPlan === 'free') {
// Create new subscription
await createSubscription({
plan: selectedPlan.value.id,
billingCycle: billingCycle.value,
paymentMethodId: selectedPaymentMethod.value
})
} else {
// Update existing subscription
await updateSubscription({
plan: selectedPlan.value.id,
billingCycle: billingCycle.value
})
}
showConfirmation.value = false
emit('upgrade', selectedPlan.value)
} catch (error) {
ElMessage.error('升级失败,请重试')
} finally {
upgrading.value = false
}
}
const loadPaymentMethods = async () => {
try {
const { data } = await getPaymentMethods()
paymentMethods.value = data.paymentMethods
// Select default payment method
const defaultMethod = paymentMethods.value.find(m => m.isDefault)
if (defaultMethod) {
selectedPaymentMethod.value = defaultMethod.id
}
} catch (error) {
console.error('Failed to load payment methods:', error)
}
}
// Helpers
const formatCurrency = (amount) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0
}).format(amount)
}
const getBillingCycleText = () => {
return billingCycle.value === 'yearly' ? '年' : '月'
}
const getPaymentMethodLabel = (method) => {
if (method.type === 'card') {
return `${method.card.brand} **** ${method.card.last4}`
}
return `${method.bankAccount.bankName} **** ${method.bankAccount.last4}`
}
// Lifecycle
onMounted(() => {
loadPaymentMethods()
})
</script>
<style scoped>
.subscription-upgrade {
padding: 20px;
}
.plan-option {
height: 100%;
position: relative;
transition: all 0.3s;
cursor: pointer;
}
.plan-option:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.plan-option.current {
border: 2px solid #409EFF;
}
.plan-option.recommended {
border: 2px solid #67C23A;
}
.recommended-badge {
position: absolute;
top: -10px;
right: 20px;
background: #67C23A;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
}
.plan-option h3 {
text-align: center;
margin-bottom: 20px;
}
.price {
text-align: center;
margin-bottom: 10px;
}
.price .amount {
font-size: 32px;
font-weight: bold;
color: #409EFF;
}
.price .period {
font-size: 16px;
color: #909399;
}
.price-yearly {
text-align: center;
margin-bottom: 20px;
font-size: 14px;
color: #67C23A;
}
.features {
list-style: none;
padding: 0;
margin: 20px 0;
}
.features li {
padding: 8px 0;
display: flex;
align-items: center;
gap: 8px;
}
.billing-cycle-toggle {
text-align: center;
margin: 30px 0;
}
.feature-comparison {
margin-top: 40px;
}
.feature-comparison h3 {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,598 @@
<template>
<div class="space-y-6" v-loading="loading">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<el-button circle @click="$router.back()">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<div>
<h1 class="text-2xl font-bold">{{ campaign.name }}</h1>
<p class="text-gray-600">{{ campaign.description }}</p>
</div>
</div>
<div class="flex gap-3">
<el-button
v-if="campaign.status === 'draft'"
type="primary"
@click="executeCampaign"
>
Execute Campaign
</el-button>
<el-button
v-if="campaign.status === 'active'"
type="warning"
@click="pauseCampaign"
>
Pause Campaign
</el-button>
<el-button
v-if="campaign.status === 'paused'"
type="success"
@click="resumeCampaign"
>
Resume Campaign
</el-button>
<el-dropdown @command="handleCommand">
<el-button>
More
<el-icon class="ml-1"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="campaign.status === 'draft'" command="edit">
Edit Campaign
</el-dropdown-item>
<el-dropdown-item command="clone">Clone Campaign</el-dropdown-item>
<el-dropdown-item command="schedule">Schedule Campaign</el-dropdown-item>
<el-dropdown-item command="export">Export Data</el-dropdown-item>
<el-dropdown-item
v-if="campaign.status !== 'active'"
command="delete"
divided
>
<span class="text-red-500">Delete Campaign</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- Campaign Status Overview -->
<el-row :gutter="20">
<el-col :span="6">
<el-card>
<el-statistic title="Status">
<template #suffix>
<el-tag :type="getStatusType(campaign.status)" size="large">
{{ formatStatus(campaign.status) }}
</el-tag>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic
title="Messages Sent"
:value="campaign.statistics?.messagesSent || 0"
/>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic
title="Delivery Rate"
:value="calculateDeliveryRate()"
suffix="%"
/>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic
title="Progress"
:value="getProgress()"
suffix="%"
>
<template #title>
<span>Progress</span>
<el-progress
:percentage="getProgress()"
:status="getProgressStatus()"
class="mt-2"
/>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<!-- Tab Navigation -->
<el-tabs v-model="activeTab">
<el-tab-pane label="Overview" name="overview">
<div class="space-y-4">
<!-- Campaign Information -->
<el-card>
<template #header>
<h3 class="text-lg font-semibold">Campaign Information</h3>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="Type">
{{ formatType(campaign.type) }}
</el-descriptions-item>
<el-descriptions-item label="Created">
{{ formatDate(campaign.createdAt) }}
</el-descriptions-item>
<el-descriptions-item label="Start Date">
{{ formatDate(campaign.startDate) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="End Date">
{{ formatDate(campaign.endDate) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Budget">
${{ campaign.budget || '0.00' }}
</el-descriptions-item>
<el-descriptions-item label="Created By">
{{ campaign.createdBy }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- Target Audience -->
<el-card>
<template #header>
<h3 class="text-lg font-semibold">Target Audience</h3>
</template>
<div v-if="campaign.targetAudience">
<p><strong>Type:</strong> {{ campaign.targetAudience.type }}</p>
<div v-if="campaign.targetAudience.groups?.length" class="mt-2">
<strong>Groups:</strong>
<el-tag
v-for="group in campaign.targetAudience.groups"
:key="group"
class="ml-2"
>
{{ group }}
</el-tag>
</div>
</div>
</el-card>
<!-- Message Content -->
<el-card>
<template #header>
<h3 class="text-lg font-semibold">Message Content</h3>
</template>
<div v-if="campaign.metadata?.message">
<div class="bg-gray-100 p-4 rounded-lg whitespace-pre-wrap">
{{ campaign.metadata.message.content }}
</div>
<div class="mt-2">
<el-tag size="small">{{ campaign.metadata.message.parseMode || 'md' }}</el-tag>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<el-tab-pane label="Analytics" name="analytics">
<div class="space-y-4">
<!-- Performance Metrics -->
<el-row :gutter="20">
<el-col :span="8">
<el-card>
<el-statistic
title="Total Recipients"
:value="progress.statistics?.totalTasks || 0"
/>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<el-statistic
title="Messages Delivered"
:value="progress.statistics?.messagesSent || 0"
:precision="0"
>
<template #suffix>
<span class="text-green-500">
({{ calculateDeliveryRate() }}%)
</span>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<el-statistic
title="Failed Messages"
:value="progress.statistics?.failedTasks || 0"
:precision="0"
>
<template #suffix>
<span class="text-red-500">
({{ calculateFailureRate() }}%)
</span>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<!-- Queue Status -->
<el-card v-if="progress.queue">
<template #header>
<h3 class="text-lg font-semibold">Queue Status</h3>
</template>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="Waiting" :value="progress.queue.waiting" />
</el-col>
<el-col :span="6">
<el-statistic title="Active" :value="progress.queue.active" />
</el-col>
<el-col :span="6">
<el-statistic title="Completed" :value="progress.queue.completed" />
</el-col>
<el-col :span="6">
<el-statistic title="Failed" :value="progress.queue.failed" />
</el-col>
</el-row>
</el-card>
<!-- Hourly Performance Chart (Mock) -->
<el-card>
<template #header>
<h3 class="text-lg font-semibold">Hourly Performance</h3>
</template>
<div class="text-center py-12 text-gray-500">
Performance chart will be displayed here
</div>
</el-card>
</div>
</el-tab-pane>
<el-tab-pane label="Message History" name="history">
<el-card>
<el-table :data="messageHistory" stripe>
<el-table-column prop="recipient" label="Recipient" />
<el-table-column prop="status" label="Status">
<template #default="{ row }">
<el-tag :type="row.status === 'delivered' ? 'success' : 'danger'" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sentAt" label="Sent At">
<template #default="{ row }">
{{ formatDate(row.sentAt) }}
</template>
</el-table-column>
<el-table-column label="Actions" width="100">
<template #default="{ row }">
<el-button size="small" text @click="viewMessage(row)">
View
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="Settings" name="settings">
<el-card>
<el-form label-position="top">
<el-form-item label="Campaign Goals">
<el-descriptions :column="1" border>
<el-descriptions-item label="Primary Goal">
{{ campaign.goals?.primary || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Success Metrics">
<el-tag
v-for="metric in campaign.goals?.metrics || []"
:key="metric"
class="mr-2"
>
{{ metric }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-form-item>
<el-form-item label="Campaign Actions">
<el-space>
<el-button @click="exportData">Export Campaign Data</el-button>
<el-button @click="duplicateCampaign">Duplicate Campaign</el-button>
<el-button type="danger" @click="deleteCampaign">Delete Campaign</el-button>
</el-space>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
<!-- Auto-refresh indicator -->
<div v-if="campaign.status === 'active'" class="text-center text-sm text-gray-500">
Auto-refreshing every 10 seconds...
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import api from '@/api'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const activeTab = ref('overview')
const campaign = ref({})
const progress = ref({})
const messageHistory = ref([])
let refreshInterval = null
const campaignId = computed(() => route.params.id)
onMounted(() => {
loadCampaign()
loadProgress()
// Auto-refresh if campaign is active
refreshInterval = setInterval(() => {
if (campaign.value.status === 'active') {
loadProgress()
}
}, 10000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
const loadCampaign = async () => {
loading.value = true
try {
const response = await api.campaigns.getDetail(campaignId.value)
campaign.value = response.data.data || {}
} catch (error) {
console.error('Failed to load campaign:', error)
ElMessage.error('Failed to load campaign details')
} finally {
loading.value = false
}
}
const loadProgress = async () => {
try {
const response = await api.campaigns.getProgress(campaignId.value)
progress.value = response.data.data || {}
} catch (error) {
console.error('Failed to load progress:', error)
}
}
const executeCampaign = async () => {
try {
await ElMessageBox.confirm(
'Execute this campaign? This will start sending messages to the target audience.',
'Confirm Execution',
{
confirmButtonText: 'Execute',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
const response = await api.campaigns.execute(campaignId.value)
if (response.data.success) {
ElMessage.success('Campaign execution started')
loadCampaign()
loadProgress()
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.error || 'Failed to execute campaign')
}
}
}
const pauseCampaign = async () => {
try {
await ElMessageBox.confirm(
'Pause this campaign?',
'Confirm Pause',
{
confirmButtonText: 'Pause',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
const response = await api.campaigns.pause(campaignId.value)
if (response.data.success) {
ElMessage.success('Campaign paused')
loadCampaign()
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to pause campaign')
}
}
}
const resumeCampaign = async () => {
try {
const response = await api.campaigns.resume(campaignId.value)
if (response.data.success) {
ElMessage.success('Campaign resumed')
loadCampaign()
loadProgress()
}
} catch (error) {
ElMessage.error('Failed to resume campaign')
}
}
const handleCommand = (command) => {
switch (command) {
case 'edit':
router.push(`/dashboard/campaigns/${campaignId.value}/edit`)
break
case 'clone':
duplicateCampaign()
break
case 'schedule':
scheduleCampaign()
break
case 'export':
exportData()
break
case 'delete':
deleteCampaign()
break
}
}
const duplicateCampaign = async () => {
try {
const response = await api.campaigns.clone(campaignId.value)
if (response.data.success) {
ElMessage.success('Campaign duplicated successfully')
router.push('/dashboard/campaigns')
}
} catch (error) {
ElMessage.error('Failed to duplicate campaign')
}
}
const scheduleCampaign = () => {
router.push({
path: '/dashboard/campaigns/schedule/new',
query: { campaignId: campaignId.value }
})
}
const exportData = () => {
ElMessage.info('Export feature coming soon')
}
const deleteCampaign = async () => {
try {
await ElMessageBox.confirm(
'Delete this campaign? This action cannot be undone.',
'Confirm Delete',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
await api.campaigns.delete(campaignId.value)
ElMessage.success('Campaign deleted')
router.push('/dashboard/campaigns')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete campaign')
}
}
}
const viewMessage = (message) => {
ElMessage.info('Message details view coming soon')
}
const formatType = (type) => {
const types = {
message: 'Message',
invitation: 'Invitation',
data_collection: 'Data Collection',
engagement: 'Engagement',
custom: 'Custom'
}
return types[type] || type
}
const formatStatus = (status) => {
const statuses = {
draft: 'Draft',
scheduled: 'Scheduled',
active: 'Active',
paused: 'Paused',
completed: 'Completed',
cancelled: 'Cancelled'
}
return statuses[status] || status
}
const getStatusType = (status) => {
const types = {
draft: 'info',
scheduled: 'warning',
active: 'success',
paused: 'warning',
completed: '',
cancelled: 'danger'
}
return types[status] || ''
}
const getProgress = () => {
const stats = campaign.value.statistics || progress.value.statistics || {}
const total = stats.totalTasks || 0
const completed = stats.completedTasks || 0
if (total === 0) return 0
return Math.round((completed / total) * 100)
}
const getProgressStatus = () => {
const stats = campaign.value.statistics || progress.value.statistics || {}
if (stats.failedTasks > 0) return 'exception'
if (stats.completedTasks === stats.totalTasks) return 'success'
return ''
}
const calculateDeliveryRate = () => {
const stats = campaign.value.statistics || progress.value.statistics || {}
const sent = stats.messagesSent || 0
const total = stats.totalTasks || 0
if (total === 0) return 0
return Math.round((sent / total) * 100)
}
const calculateFailureRate = () => {
const stats = campaign.value.statistics || progress.value.statistics || {}
const failed = stats.failedTasks || 0
const total = stats.totalTasks || 0
if (total === 0) return 0
return Math.round((failed / total) * 100)
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString()
}
</script>

View File

@@ -0,0 +1,368 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Marketing Campaigns</h1>
<el-button type="primary" @click="$router.push('/dashboard/campaigns/create')">
<el-icon class="mr-2"><Plus /></el-icon>
Create Campaign
</el-button>
</div>
<!-- Filters -->
<el-card>
<div class="flex gap-4">
<el-select v-model="filters.status" placeholder="All Status" clearable @change="loadCampaigns">
<el-option label="All Status" value="" />
<el-option label="Draft" value="draft" />
<el-option label="Scheduled" value="scheduled" />
<el-option label="Active" value="active" />
<el-option label="Paused" value="paused" />
<el-option label="Completed" value="completed" />
<el-option label="Cancelled" value="cancelled" />
</el-select>
<el-select v-model="filters.type" placeholder="All Types" clearable @change="loadCampaigns">
<el-option label="All Types" value="" />
<el-option label="Message" value="message" />
<el-option label="Invitation" value="invitation" />
<el-option label="Data Collection" value="data_collection" />
<el-option label="Engagement" value="engagement" />
<el-option label="Custom" value="custom" />
</el-select>
<el-input
v-model="filters.search"
placeholder="Search campaigns..."
clearable
@clear="loadCampaigns"
@keyup.enter="loadCampaigns"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</el-card>
<!-- Campaigns Table -->
<el-card v-loading="loading">
<el-table :data="campaigns" stripe>
<el-table-column prop="name" label="Campaign Name" min-width="200">
<template #default="{ row }">
<router-link :to="`/dashboard/campaigns/${row.id}`" class="text-blue-600 hover:underline">
{{ row.name }}
</router-link>
</template>
</el-table-column>
<el-table-column prop="type" label="Type" width="150">
<template #default="{ row }">
<el-tag size="small">{{ formatType(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="Status" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ formatStatus(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Progress" width="200">
<template #default="{ row }">
<div v-if="row.status === 'active'">
<el-progress :percentage="getProgress(row)" :status="getProgressStatus(row)" />
<span class="text-xs text-gray-500">
{{ row.statistics?.messagesSent || 0 }} sent
</span>
</div>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column prop="startDate" label="Start Date" width="150">
<template #default="{ row }">
{{ formatDate(row.startDate) }}
</template>
</el-table-column>
<el-table-column label="Actions" width="200" fixed="right">
<template #default="{ row }">
<el-space>
<el-button
v-if="row.status === 'draft' || row.status === 'scheduled'"
size="small"
type="primary"
@click="executeCampaign(row)"
>
Execute
</el-button>
<el-button
v-if="row.status === 'active'"
size="small"
type="warning"
@click="pauseCampaign(row)"
>
Pause
</el-button>
<el-button
v-if="row.status === 'paused'"
size="small"
type="success"
@click="resumeCampaign(row)"
>
Resume
</el-button>
<el-dropdown @command="(cmd) => handleCommand(cmd, row)">
<el-button size="small" circle>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">View Details</el-dropdown-item>
<el-dropdown-item command="clone">Clone Campaign</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'draft'" command="edit">Edit</el-dropdown-item>
<el-dropdown-item
v-if="row.status === 'draft' || row.status === 'cancelled'"
command="delete"
divided
>
<span class="text-red-500">Delete</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-space>
</template>
</el-table-column>
</el-table>
<!-- Pagination -->
<div class="mt-4 flex justify-center">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadCampaigns"
@current-change="loadCampaigns"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, MoreFilled } from '@element-plus/icons-vue'
import api from '@/api'
const router = useRouter()
const loading = ref(false)
const campaigns = ref([])
const filters = reactive({
status: '',
type: '',
search: ''
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
onMounted(() => {
loadCampaigns()
})
const loadCampaigns = async () => {
loading.value = true
try {
const params = {
page: pagination.currentPage,
limit: pagination.pageSize,
...filters
}
const response = await api.campaigns.getList(params)
campaigns.value = response.data.data.campaigns || []
pagination.total = response.data.data.total || 0
} catch (error) {
console.error('Failed to load campaigns:', error)
ElMessage.error('Failed to load campaigns')
} finally {
loading.value = false
}
}
const executeCampaign = async (campaign) => {
try {
await ElMessageBox.confirm(
`Execute campaign "${campaign.name}"? This will start sending messages to the target audience.`,
'Confirm Execution',
{
confirmButtonText: 'Execute',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
const response = await api.campaigns.execute(campaign.id)
if (response.data.success) {
ElMessage.success('Campaign execution started')
loadCampaigns()
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.error || 'Failed to execute campaign')
}
}
}
const pauseCampaign = async (campaign) => {
try {
await ElMessageBox.confirm(
`Pause campaign "${campaign.name}"?`,
'Confirm Pause',
{
confirmButtonText: 'Pause',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
const response = await api.campaigns.pause(campaign.id)
if (response.data.success) {
ElMessage.success('Campaign paused')
loadCampaigns()
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to pause campaign')
}
}
}
const resumeCampaign = async (campaign) => {
try {
const response = await api.campaigns.resume(campaign.id)
if (response.data.success) {
ElMessage.success('Campaign resumed')
loadCampaigns()
}
} catch (error) {
ElMessage.error('Failed to resume campaign')
}
}
const handleCommand = async (command, campaign) => {
switch (command) {
case 'view':
router.push(`/dashboard/campaigns/${campaign.id}`)
break
case 'edit':
router.push(`/dashboard/campaigns/${campaign.id}/edit`)
break
case 'clone':
try {
const response = await api.campaigns.clone(campaign.id)
if (response.data.success) {
ElMessage.success('Campaign cloned successfully')
loadCampaigns()
}
} catch (error) {
ElMessage.error('Failed to clone campaign')
}
break
case 'delete':
try {
await ElMessageBox.confirm(
`Delete campaign "${campaign.name}"? This action cannot be undone.`,
'Confirm Delete',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
await api.campaigns.delete(campaign.id)
ElMessage.success('Campaign deleted')
loadCampaigns()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete campaign')
}
}
break
}
}
const formatType = (type) => {
const types = {
message: 'Message',
invitation: 'Invitation',
data_collection: 'Data Collection',
engagement: 'Engagement',
custom: 'Custom'
}
return types[type] || type
}
const formatStatus = (status) => {
const statuses = {
draft: 'Draft',
scheduled: 'Scheduled',
active: 'Active',
paused: 'Paused',
completed: 'Completed',
cancelled: 'Cancelled'
}
return statuses[status] || status
}
const getStatusType = (status) => {
const types = {
draft: 'info',
scheduled: 'warning',
active: 'success',
paused: 'warning',
completed: '',
cancelled: 'danger'
}
return types[status] || ''
}
const getProgress = (campaign) => {
const stats = campaign.statistics || {}
const total = stats.totalTasks || 0
const completed = stats.completedTasks || 0
if (total === 0) return 0
return Math.round((completed / total) * 100)
}
const getProgressStatus = (campaign) => {
const stats = campaign.statistics || {}
if (stats.failedTasks > 0) return 'exception'
if (stats.completedTasks === stats.totalTasks) return 'success'
return ''
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString()
}
</script>

View File

@@ -0,0 +1,328 @@
<template>
<div class="pb-20">
<!-- Header -->
<div class="sticky top-0 bg-white z-10 px-4 py-3 border-b">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">{{ t('menu.campaigns') }}</h1>
<router-link to="/campaigns/create">
<el-button type="primary" size="small" circle>
<el-icon><Plus /></el-icon>
</el-button>
</router-link>
</div>
<!-- Filters -->
<div class="flex items-center gap-2 mt-3">
<el-select
v-model="filters.status"
size="small"
style="width: 100px"
@change="fetchCampaigns"
>
<el-option label="All" value="" />
<el-option label="Active" value="active" />
<el-option label="Scheduled" value="scheduled" />
<el-option label="Completed" value="completed" />
</el-select>
<el-input
v-model="searchQuery"
size="small"
:prefix-icon="Search"
placeholder="Search campaigns..."
class="flex-1"
@input="handleSearch"
/>
</div>
</div>
<!-- Campaign List -->
<div v-if="loading" class="flex justify-center items-center h-64">
<el-icon class="is-loading" :size="32" color="#409EFF">
<Loading />
</el-icon>
</div>
<div v-else-if="campaigns.length === 0" class="text-center py-12">
<el-empty description="No campaigns found" />
<router-link to="/campaigns/create" class="mt-4 inline-block">
<el-button type="primary">Create First Campaign</el-button>
</router-link>
</div>
<div v-else class="divide-y divide-gray-100">
<div
v-for="campaign in campaigns"
:key="campaign.id"
class="p-4 hover:bg-gray-50 active:bg-gray-100"
@click="$router.push(`/campaigns/${campaign.id}`)"
>
<!-- Campaign Header -->
<div class="flex items-start justify-between mb-2">
<div class="flex-1 min-w-0">
<h3 class="text-sm font-medium text-gray-900 truncate">
{{ campaign.name }}
</h3>
<p class="text-xs text-gray-500 mt-1">
{{ formatDate(campaign.createdAt) }}
</p>
</div>
<el-tag
:type="getStatusType(campaign.status)"
size="small"
effect="plain"
>
{{ campaign.status }}
</el-tag>
</div>
<!-- Campaign Stats -->
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="text-center">
<div class="text-gray-500">Recipients</div>
<div class="font-medium">{{ formatNumber(campaign.recipients) }}</div>
</div>
<div class="text-center">
<div class="text-gray-500">Delivered</div>
<div class="font-medium">{{ formatNumber(campaign.delivered) }}</div>
</div>
<div class="text-center">
<div class="text-gray-500">Engagement</div>
<div class="font-medium">{{ campaign.engagement }}%</div>
</div>
</div>
<!-- Progress Bar -->
<el-progress
v-if="campaign.status === 'active'"
:percentage="campaign.progress"
:stroke-width="4"
class="mt-2"
:show-text="false"
/>
<!-- Actions -->
<div class="flex items-center justify-between mt-3">
<div class="flex items-center gap-3 text-xs text-gray-600">
<span>
<el-icon><User /></el-icon>
{{ campaign.createdBy }}
</span>
<span v-if="campaign.scheduledAt">
<el-icon><Calendar /></el-icon>
{{ formatDate(campaign.scheduledAt) }}
</span>
</div>
<el-dropdown @command="(cmd) => handleAction(cmd, campaign)" @click.stop>
<el-button text size="small">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">View Details</el-dropdown-item>
<el-dropdown-item command="edit" v-if="canEdit(campaign)">
Edit
</el-dropdown-item>
<el-dropdown-item command="duplicate">Duplicate</el-dropdown-item>
<el-dropdown-item
command="pause"
v-if="campaign.status === 'active'"
>
Pause
</el-dropdown-item>
<el-dropdown-item
command="resume"
v-if="campaign.status === 'paused'"
>
Resume
</el-dropdown-item>
<el-dropdown-item
command="delete"
divided
v-if="canDelete(campaign)"
>
Delete
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- Load More -->
<div v-if="hasMore" class="p-4">
<el-button
:loading="loadingMore"
@click="loadMore"
block
>
Load More
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Search,
Loading,
User,
Calendar,
MoreFilled
} from '@element-plus/icons-vue'
import api from '@/api'
import { formatDate, formatNumber } from '@/utils/date'
const { t } = useI18n()
const router = useRouter()
const campaigns = ref([])
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const searchQuery = ref('')
const page = ref(1)
const pageSize = 10
const filters = reactive({
status: '',
search: ''
})
const getStatusType = (status) => {
const types = {
active: 'success',
scheduled: 'info',
completed: 'info',
paused: 'warning',
failed: 'danger'
}
return types[status] || 'info'
}
const canEdit = (campaign) => {
return ['draft', 'scheduled'].includes(campaign.status)
}
const canDelete = (campaign) => {
return ['draft', 'completed', 'failed'].includes(campaign.status)
}
const fetchCampaigns = async (append = false) => {
if (!append) {
loading.value = true
page.value = 1
} else {
loadingMore.value = true
}
try {
const { data } = await api.campaigns.list({
page: page.value,
pageSize,
status: filters.status,
search: filters.search
})
if (append) {
campaigns.value.push(...data.items)
} else {
campaigns.value = data.items
}
hasMore.value = data.total > campaigns.value.length
} catch (error) {
ElMessage.error('Failed to load campaigns')
} finally {
loading.value = false
loadingMore.value = false
}
}
const loadMore = () => {
page.value++
fetchCampaigns(true)
}
const handleSearch = () => {
filters.search = searchQuery.value
fetchCampaigns()
}
const handleAction = async (command, campaign) => {
switch (command) {
case 'view':
router.push(`/campaigns/${campaign.id}`)
break
case 'edit':
router.push(`/campaigns/${campaign.id}/edit`)
break
case 'duplicate':
router.push(`/campaigns/create?duplicate=${campaign.id}`)
break
case 'pause':
await updateCampaignStatus(campaign.id, 'paused')
break
case 'resume':
await updateCampaignStatus(campaign.id, 'active')
break
case 'delete':
await deleteCampaign(campaign)
break
}
}
const updateCampaignStatus = async (id, status) => {
try {
await api.campaigns.updateStatus(id, status)
ElMessage.success(`Campaign ${status}`)
fetchCampaigns()
} catch (error) {
ElMessage.error('Failed to update campaign status')
}
}
const deleteCampaign = async (campaign) => {
try {
await ElMessageBox.confirm(
`Delete campaign "${campaign.name}"?`,
'Confirm Delete',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
await api.campaigns.delete(campaign.id)
ElMessage.success('Campaign deleted')
fetchCampaigns()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete campaign')
}
}
}
onMounted(() => {
fetchCampaigns()
})
</script>
<style scoped>
.el-progress {
line-height: 1;
}
</style>

View File

@@ -0,0 +1,477 @@
<template>
<div class="campaign-scheduler">
<el-card>
<template #header>
<div class="card-header">
<h3>{{ isEditMode ? 'Edit' : 'Create' }} Scheduled Campaign</h3>
</div>
</template>
<el-form
ref="scheduleForm"
:model="scheduleData"
:rules="rules"
label-width="150px"
>
<!-- Campaign Selection -->
<el-form-item label="Campaign" prop="campaignId" v-if="!isEditMode">
<el-select
v-model="scheduleData.campaignId"
placeholder="Select a campaign"
@change="onCampaignChange"
>
<el-option
v-for="campaign in campaigns"
:key="campaign.id"
:label="campaign.name"
:value="campaign.id"
/>
</el-select>
</el-form-item>
<!-- Campaign Name -->
<el-form-item label="Schedule Name" prop="campaignName">
<el-input v-model="scheduleData.campaignName" />
</el-form-item>
<!-- Schedule Type -->
<el-form-item label="Schedule Type" prop="type">
<el-radio-group v-model="scheduleData.type">
<el-radio label="one-time">One-time</el-radio>
<el-radio label="recurring">Recurring</el-radio>
<el-radio label="trigger-based">Trigger-based</el-radio>
</el-radio-group>
</el-form-item>
<!-- One-time Schedule -->
<div v-if="scheduleData.type === 'one-time'">
<el-form-item label="Start Date/Time" prop="schedule.startDateTime">
<el-date-picker
v-model="scheduleData.schedule.startDateTime"
type="datetime"
placeholder="Select date and time"
:disabled-date="disabledDate"
/>
</el-form-item>
</div>
<!-- Recurring Schedule -->
<div v-if="scheduleData.type === 'recurring'">
<el-form-item label="Pattern" prop="schedule.recurring.pattern">
<el-select v-model="scheduleData.schedule.recurring.pattern">
<el-option label="Daily" value="daily" />
<el-option label="Weekly" value="weekly" />
<el-option label="Monthly" value="monthly" />
<el-option label="Custom" value="custom" />
</el-select>
</el-form-item>
<!-- Custom Frequency -->
<div v-if="scheduleData.schedule.recurring.pattern === 'custom'">
<el-form-item label="Frequency">
<el-input-number
v-model="scheduleData.schedule.recurring.frequency.interval"
:min="1"
style="width: 100px"
/>
<el-select
v-model="scheduleData.schedule.recurring.frequency.unit"
style="width: 120px; margin-left: 10px"
>
<el-option label="Minutes" value="minutes" />
<el-option label="Hours" value="hours" />
<el-option label="Days" value="days" />
<el-option label="Weeks" value="weeks" />
<el-option label="Months" value="months" />
</el-select>
</el-form-item>
</div>
<!-- Weekly Options -->
<el-form-item
label="Days of Week"
v-if="scheduleData.schedule.recurring.pattern === 'weekly'"
>
<el-checkbox-group v-model="scheduleData.schedule.recurring.daysOfWeek">
<el-checkbox :label="0">Sunday</el-checkbox>
<el-checkbox :label="1">Monday</el-checkbox>
<el-checkbox :label="2">Tuesday</el-checkbox>
<el-checkbox :label="3">Wednesday</el-checkbox>
<el-checkbox :label="4">Thursday</el-checkbox>
<el-checkbox :label="5">Friday</el-checkbox>
<el-checkbox :label="6">Saturday</el-checkbox>
</el-checkbox-group>
</el-form-item>
<!-- Monthly Options -->
<el-form-item
label="Days of Month"
v-if="scheduleData.schedule.recurring.pattern === 'monthly'"
>
<el-select
v-model="scheduleData.schedule.recurring.daysOfMonth"
multiple
placeholder="Select days"
>
<el-option
v-for="day in 31"
:key="day"
:label="day"
:value="day"
/>
</el-select>
</el-form-item>
<!-- Time of Day -->
<el-form-item label="Time of Day" prop="schedule.recurring.timeOfDay">
<el-time-picker
v-model="scheduleData.schedule.recurring.timeOfDay"
format="HH:mm"
value-format="HH:mm"
placeholder="Select time"
/>
</el-form-item>
<!-- Timezone -->
<el-form-item label="Timezone" prop="schedule.recurring.timezone">
<el-select v-model="scheduleData.schedule.recurring.timezone">
<el-option label="UTC" value="UTC" />
<el-option label="America/New_York" value="America/New_York" />
<el-option label="America/Los_Angeles" value="America/Los_Angeles" />
<el-option label="Europe/London" value="Europe/London" />
<el-option label="Europe/Paris" value="Europe/Paris" />
<el-option label="Asia/Shanghai" value="Asia/Shanghai" />
<el-option label="Asia/Tokyo" value="Asia/Tokyo" />
</el-select>
</el-form-item>
<!-- End Conditions -->
<el-form-item label="End Date">
<el-date-picker
v-model="scheduleData.schedule.recurring.endDate"
type="date"
placeholder="Optional end date"
:disabled-date="disabledDate"
/>
</el-form-item>
<el-form-item label="Max Occurrences">
<el-input-number
v-model="scheduleData.schedule.recurring.maxOccurrences"
:min="1"
placeholder="Unlimited"
/>
</el-form-item>
</div>
<!-- Target Audience -->
<el-form-item label="Target Audience" prop="targetAudience.type">
<el-select v-model="scheduleData.targetAudience.type">
<el-option label="All Users" value="all" />
<el-option label="Segment" value="segment" />
<el-option label="Group" value="group" />
<el-option label="Individual Users" value="individual" />
</el-select>
</el-form-item>
<!-- Audience Selection -->
<el-form-item
label="Select Segment"
v-if="scheduleData.targetAudience.type === 'segment'"
>
<el-select v-model="scheduleData.targetAudience.segmentId">
<el-option
v-for="segment in segments"
:key="segment.id"
:label="segment.name"
:value="segment.id"
/>
</el-select>
</el-form-item>
<!-- Message Configuration -->
<el-form-item label="Message Template">
<el-select v-model="scheduleData.messageConfig.templateId">
<el-option
v-for="template in templates"
:key="template.id"
:label="template.name"
:value="template.id"
/>
</el-select>
</el-form-item>
<!-- Delivery Settings -->
<el-form-item label="Priority">
<el-select v-model="scheduleData.deliverySettings.priority">
<el-option label="Low" value="low" />
<el-option label="Normal" value="normal" />
<el-option label="High" value="high" />
<el-option label="Critical" value="critical" />
</el-select>
</el-form-item>
<!-- Rate Limiting -->
<el-form-item label="Rate Limiting">
<el-switch v-model="scheduleData.deliverySettings.rateLimiting.enabled" />
<div v-if="scheduleData.deliverySettings.rateLimiting.enabled" style="margin-top: 10px">
<el-input-number
v-model="scheduleData.deliverySettings.rateLimiting.messagesPerHour"
:min="1"
style="width: 150px"
/>
<span style="margin-left: 10px">messages per hour</span>
</div>
</el-form-item>
<!-- Actions -->
<el-form-item>
<el-button type="primary" @click="saveSchedule">Save</el-button>
<el-button @click="cancel">Cancel</el-button>
<el-button type="info" @click="previewSchedule" v-if="scheduleData.type === 'recurring'">
Preview Schedule
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Schedule Preview Dialog -->
<el-dialog
title="Schedule Preview"
v-model="showPreview"
width="600px"
>
<div v-if="previewOccurrences.length > 0">
<h4>Next {{ previewOccurrences.length }} Occurrences:</h4>
<el-timeline>
<el-timeline-item
v-for="(occurrence, index) in previewOccurrences"
:key="index"
:timestamp="formatDateTime(occurrence)"
>
Run #{{ index + 1 }}
</el-timeline-item>
</el-timeline>
</div>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import api from '@/api';
import { formatDateTime } from '@/utils/date';
export default {
name: 'CampaignScheduler',
setup() {
const router = useRouter();
const route = useRoute();
const scheduleForm = ref(null);
const campaigns = ref([]);
const segments = ref([]);
const templates = ref([]);
const showPreview = ref(false);
const previewOccurrences = ref([]);
const isEditMode = computed(() => !!route.params.id);
const scheduleData = reactive({
campaignId: '',
campaignName: '',
type: 'one-time',
schedule: {
startDateTime: null,
recurring: {
pattern: 'daily',
frequency: {
interval: 1,
unit: 'days'
},
daysOfWeek: [],
daysOfMonth: [],
timeOfDay: '09:00',
timezone: 'UTC',
endDate: null,
maxOccurrences: null
},
triggers: []
},
targetAudience: {
type: 'all',
segmentId: null,
groupIds: [],
userIds: []
},
messageConfig: {
templateId: null,
personalization: {
enabled: true,
fields: []
}
},
deliverySettings: {
priority: 'normal',
rateLimiting: {
enabled: true,
messagesPerHour: 1000
},
retryPolicy: {
maxRetries: 3,
retryDelay: 300
},
quietHours: {
enabled: true,
start: '22:00',
end: '08:00',
timezone: 'UTC'
}
}
});
const rules = {
campaignId: [
{ required: true, message: 'Please select a campaign', trigger: 'change' }
],
campaignName: [
{ required: true, message: 'Please enter a schedule name', trigger: 'blur' }
],
type: [
{ required: true, message: 'Please select a schedule type', trigger: 'change' }
],
'schedule.startDateTime': [
{ required: true, message: 'Please select start date/time', trigger: 'change' }
],
'schedule.recurring.pattern': [
{ required: true, message: 'Please select a pattern', trigger: 'change' }
],
'schedule.recurring.timeOfDay': [
{ required: true, message: 'Please select time of day', trigger: 'change' }
],
'targetAudience.type': [
{ required: true, message: 'Please select target audience', trigger: 'change' }
]
};
const disabledDate = (date) => {
return date && date.valueOf() < Date.now() - 86400000;
};
const loadData = async () => {
try {
const [campaignsRes, segmentsRes, templatesRes] = await Promise.all([
api.campaigns.getAll(),
api.segments.getAll(),
api.templates.getAll()
]);
campaigns.value = campaignsRes.data;
segments.value = segmentsRes.data;
templates.value = templatesRes.data;
if (isEditMode.value) {
const res = await api.scheduledCampaigns.get(route.params.id);
Object.assign(scheduleData, res.data);
} else if (route.query.campaignId) {
// Pre-fill campaign if coming from campaign detail page
scheduleData.campaignId = route.query.campaignId;
onCampaignChange(route.query.campaignId);
}
} catch (error) {
ElMessage.error('Failed to load data');
}
};
const onCampaignChange = (campaignId) => {
const campaign = campaigns.value.find(c => c.id === campaignId);
if (campaign) {
scheduleData.campaignName = `${campaign.name} - Scheduled`;
}
};
const saveSchedule = async () => {
try {
const valid = await scheduleForm.value.validate();
if (!valid) return;
if (isEditMode.value) {
await api.scheduledCampaigns.update(route.params.id, scheduleData);
ElMessage.success('Schedule updated successfully');
} else {
await api.scheduledCampaigns.create(scheduleData);
ElMessage.success('Schedule created successfully');
}
router.push('/campaigns/schedules');
} catch (error) {
ElMessage.error(error.response?.data?.error || 'Failed to save schedule');
}
};
const cancel = () => {
router.push('/campaigns/schedules');
};
const previewSchedule = async () => {
// Simple preview calculation
const occurrences = [];
let currentDate = new Date();
for (let i = 0; i < 10; i++) {
if (scheduleData.schedule.recurring.pattern === 'daily') {
currentDate.setDate(currentDate.getDate() + 1);
} else if (scheduleData.schedule.recurring.pattern === 'weekly') {
currentDate.setDate(currentDate.getDate() + 7);
} else if (scheduleData.schedule.recurring.pattern === 'monthly') {
currentDate.setMonth(currentDate.getMonth() + 1);
}
occurrences.push(new Date(currentDate));
}
previewOccurrences.value = occurrences;
showPreview.value = true;
};
onMounted(() => {
loadData();
});
return {
scheduleForm,
scheduleData,
rules,
campaigns,
segments,
templates,
isEditMode,
showPreview,
previewOccurrences,
disabledDate,
onCampaignChange,
saveSchedule,
cancel,
previewSchedule,
formatDateTime
};
}
};
</script>
<style scoped>
.campaign-scheduler {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
}
</style>

View File

@@ -0,0 +1,460 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<el-button circle @click="$router.back()">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<h1 class="text-2xl font-bold">Create Campaign</h1>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
v-loading="saving"
>
<el-card>
<template #header>
<h3 class="text-lg font-semibold">Basic Information</h3>
</template>
<el-form-item label="Campaign Name" prop="name">
<el-input
v-model="form.name"
placeholder="Enter campaign name"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="Description" prop="description">
<el-input
v-model="form.description"
type="textarea"
placeholder="Describe your campaign objectives and target audience"
:rows="3"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="Campaign Type" prop="type">
<el-select v-model="form.type" placeholder="Select type" style="width: 100%">
<el-option label="Message Campaign" value="message" />
<el-option label="Invitation Campaign" value="invitation" />
<el-option label="Data Collection" value="data_collection" />
<el-option label="Engagement Campaign" value="engagement" />
<el-option label="Custom Campaign" value="custom" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Budget (Optional)">
<el-input-number
v-model="form.budget"
:min="0"
:precision="2"
placeholder="0.00"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="Start Date">
<el-date-picker
v-model="form.startDate"
type="datetime"
placeholder="Select start date"
style="width: 100%"
:disabled-date="disabledStartDate"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="End Date">
<el-date-picker
v-model="form.endDate"
type="datetime"
placeholder="Select end date"
style="width: 100%"
:disabled-date="disabledEndDate"
/>
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-card>
<template #header>
<h3 class="text-lg font-semibold">Target Audience</h3>
</template>
<el-form-item label="Audience Type">
<el-radio-group v-model="form.targetAudience.type">
<el-radio label="all">All Contacts</el-radio>
<el-radio label="groups">Specific Groups</el-radio>
<el-radio label="custom">Custom List</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.targetAudience.type === 'groups'" label="Select Groups">
<el-select
v-model="form.targetAudience.groups"
multiple
placeholder="Select groups"
style="width: 100%"
>
<el-option
v-for="group in availableGroups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.targetAudience.type === 'custom'" label="Custom Recipients">
<el-input
v-model="form.targetAudience.customList"
type="textarea"
placeholder="Enter phone numbers or usernames, one per line"
:rows="5"
/>
</el-form-item>
<el-form-item label="Estimated Reach">
<div class="text-2xl font-semibold text-blue-600">
{{ estimatedReach }} recipients
</div>
</el-form-item>
</el-card>
<el-card>
<template #header>
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Message Content</h3>
<el-button text type="primary" @click="showTemplateDialog = true">
Use Template
</el-button>
</div>
</template>
<el-form-item label="Message Template" prop="message.content">
<el-input
v-model="form.message.content"
type="textarea"
placeholder="Enter your message content. You can use variables like {{name}}, {{company}}, etc."
:rows="6"
maxlength="4096"
show-word-limit
/>
</el-form-item>
<el-form-item label="Parse Mode">
<el-radio-group v-model="form.message.parseMode">
<el-radio label="md">Markdown</el-radio>
<el-radio label="html">HTML</el-radio>
<el-radio label="plain">Plain Text</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="Add Buttons (Optional)">
<div v-for="(button, index) in form.message.buttons" :key="index" class="mb-2">
<el-row :gutter="10">
<el-col :span="10">
<el-input v-model="button.text" placeholder="Button text" />
</el-col>
<el-col :span="10">
<el-input v-model="button.url" placeholder="Button URL" />
</el-col>
<el-col :span="4">
<el-button @click="removeButton(index)" type="danger" circle>
<el-icon><Delete /></el-icon>
</el-button>
</el-col>
</el-row>
</div>
<el-button @click="addButton" type="primary" text>
<el-icon class="mr-1"><Plus /></el-icon>
Add Button
</el-button>
</el-form-item>
<el-divider />
<div>
<h4 class="font-semibold mb-2">Message Preview</h4>
<div class="bg-gray-100 p-4 rounded-lg whitespace-pre-wrap">
{{ previewMessage }}
</div>
</div>
</el-card>
<el-card>
<template #header>
<h3 class="text-lg font-semibold">Campaign Goals</h3>
</template>
<el-form-item label="Primary Goal">
<el-select v-model="form.goals.primary" placeholder="Select primary goal" style="width: 100%">
<el-option label="Increase Engagement" value="engagement" />
<el-option label="Drive Traffic" value="traffic" />
<el-option label="Generate Leads" value="leads" />
<el-option label="Boost Sales" value="sales" />
<el-option label="Brand Awareness" value="awareness" />
<el-option label="Customer Retention" value="retention" />
</el-select>
</el-form-item>
<el-form-item label="Success Metrics">
<el-checkbox-group v-model="form.goals.metrics">
<el-checkbox label="deliveryRate">Delivery Rate > 95%</el-checkbox>
<el-checkbox label="clickRate">Click Rate > 10%</el-checkbox>
<el-checkbox label="conversionRate">Conversion Rate > 5%</el-checkbox>
<el-checkbox label="responseRate">Response Rate > 20%</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-card>
<div class="flex justify-end gap-3">
<el-button @click="$router.back()">Cancel</el-button>
<el-button @click="saveDraft">Save as Draft</el-button>
<el-button type="primary" @click="createAndExecute">Create & Execute</el-button>
</div>
</el-form>
<!-- Template Selection Dialog -->
<el-dialog
v-model="showTemplateDialog"
title="Select Message Template"
width="600px"
>
<div class="space-y-4">
<el-input
v-model="templateSearch"
placeholder="Search templates..."
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="space-y-2 max-h-96 overflow-y-auto">
<el-card
v-for="template in filteredTemplates"
:key="template.id"
shadow="hover"
class="cursor-pointer"
@click="selectTemplate(template)"
>
<h4 class="font-semibold">{{ template.name }}</h4>
<p class="text-sm text-gray-600 mt-1">{{ template.content }}</p>
<div class="mt-2">
<el-tag size="small">{{ template.category }}</el-tag>
<span class="text-xs text-gray-500 ml-2">
Used {{ template.usage }} times
</span>
</div>
</el-card>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Plus, Delete, Search } from '@element-plus/icons-vue'
import api from '@/api'
const router = useRouter()
const formRef = ref()
const saving = ref(false)
const showTemplateDialog = ref(false)
const templateSearch = ref('')
const form = reactive({
name: '',
description: '',
type: 'message',
budget: null,
startDate: null,
endDate: null,
targetAudience: {
type: 'all',
groups: [],
customList: ''
},
message: {
content: '',
parseMode: 'md',
buttons: []
},
goals: {
primary: '',
metrics: []
}
})
const rules = {
name: [
{ required: true, message: 'Please enter campaign name', trigger: 'blur' },
{ min: 3, max: 100, message: 'Name must be 3-100 characters', trigger: 'blur' }
],
type: [
{ required: true, message: 'Please select campaign type', trigger: 'change' }
],
'message.content': [
{ required: true, message: 'Please enter message content', trigger: 'blur' },
{ min: 10, message: 'Message must be at least 10 characters', trigger: 'blur' }
]
}
const availableGroups = ref([])
const templates = ref([])
const estimatedReach = computed(() => {
if (form.targetAudience.type === 'all') {
return '1,234' // Mock data
} else if (form.targetAudience.type === 'groups') {
return form.targetAudience.groups.length * 100 // Mock calculation
} else {
const lines = form.targetAudience.customList.split('\n').filter(line => line.trim())
return lines.length
}
})
const previewMessage = computed(() => {
let message = form.message.content || 'Your message will appear here...'
// Replace common variables with sample data
message = message.replace(/{{name}}/g, 'John Doe')
message = message.replace(/{{company}}/g, 'Your Company')
message = message.replace(/{{date}}/g, new Date().toLocaleDateString())
return message
})
const filteredTemplates = computed(() => {
if (!templateSearch.value) return templates.value
const search = templateSearch.value.toLowerCase()
return templates.value.filter(template =>
template.name.toLowerCase().includes(search) ||
template.content.toLowerCase().includes(search) ||
template.category.toLowerCase().includes(search)
)
})
onMounted(() => {
loadTemplates()
loadGroups()
})
const loadTemplates = async () => {
try {
const response = await api.campaigns.getTemplates()
templates.value = response.data.data || []
} catch (error) {
console.error('Failed to load templates:', error)
}
}
const loadGroups = async () => {
// Mock data for now
availableGroups.value = [
{ id: 'g1', name: 'VIP Customers' },
{ id: 'g2', name: 'Newsletter Subscribers' },
{ id: 'g3', name: 'Active Users' },
{ id: 'g4', name: 'New Users' }
]
}
const selectTemplate = (template) => {
form.message.content = template.content
showTemplateDialog.value = false
ElMessage.success('Template applied')
}
const addButton = () => {
form.message.buttons.push({ text: '', url: '' })
}
const removeButton = (index) => {
form.message.buttons.splice(index, 1)
}
const disabledStartDate = (date) => {
// Disable dates before today
return date < new Date(new Date().setHours(0, 0, 0, 0))
}
const disabledEndDate = (date) => {
// Disable dates before start date
if (!form.startDate) return false
return date < form.startDate
}
const saveDraft = async () => {
const valid = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
const response = await api.campaigns.create({
...form,
status: 'draft'
})
if (response.data.success) {
ElMessage.success('Campaign saved as draft')
router.push('/dashboard/campaigns')
}
} catch (error) {
ElMessage.error(error.response?.data?.error || 'Failed to save campaign')
} finally {
saving.value = false
}
}
const createAndExecute = async () => {
const valid = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
// Create campaign
const createResponse = await api.campaigns.create({
...form,
status: 'scheduled'
})
if (createResponse.data.success) {
const campaignId = createResponse.data.data.id
// Execute campaign
const executeResponse = await api.campaigns.execute(campaignId)
if (executeResponse.data.success) {
ElMessage.success('Campaign created and execution started!')
router.push(`/dashboard/campaigns/${campaignId}`)
}
}
} catch (error) {
ElMessage.error(error.response?.data?.error || 'Failed to create campaign')
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,446 @@
<template>
<div class="scheduled-campaigns">
<div class="page-header">
<h2>Scheduled Campaigns</h2>
<el-button type="primary" @click="createSchedule" icon="Plus">
Create Schedule
</el-button>
</div>
<!-- Filters -->
<el-card class="filter-card">
<el-form :inline="true">
<el-form-item label="Status">
<el-select v-model="filters.status" placeholder="All Status" clearable>
<el-option label="Draft" value="draft" />
<el-option label="Scheduled" value="scheduled" />
<el-option label="Active" value="active" />
<el-option label="Paused" value="paused" />
<el-option label="Completed" value="completed" />
<el-option label="Cancelled" value="cancelled" />
<el-option label="Failed" value="failed" />
</el-select>
</el-form-item>
<el-form-item label="Type">
<el-select v-model="filters.type" placeholder="All Types" clearable>
<el-option label="One-time" value="one-time" />
<el-option label="Recurring" value="recurring" />
<el-option label="Trigger-based" value="trigger-based" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadSchedules">Search</el-button>
<el-button @click="resetFilters">Reset</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Statistics -->
<el-row :gutter="20" class="stats-row">
<el-col :span="6">
<el-card>
<el-statistic
title="Total Campaigns"
:value="statistics.totalCampaigns"
/>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic
title="Active Campaigns"
:value="statistics.activeCampaigns"
/>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic
title="Messages Sent"
:value="statistics.totalMessagesSent"
/>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic
title="Avg Delivery Rate"
:value="statistics.avgDeliveryRate"
suffix="%"
/>
</el-card>
</el-col>
</el-row>
<!-- Campaigns Table -->
<el-card>
<el-table
:data="campaigns"
v-loading="loading"
style="width: 100%"
>
<el-table-column prop="campaignName" label="Campaign Name" min-width="200" />
<el-table-column prop="type" label="Type" width="120">
<template #default="{ row }">
<el-tag :type="getTypeColor(row.type)">
{{ row.type }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="Status" width="120">
<template #default="{ row }">
<el-tag :type="getStatusColor(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Schedule" min-width="200">
<template #default="{ row }">
{{ getScheduleDescription(row) }}
</template>
</el-table-column>
<el-table-column label="Next Run" width="180">
<template #default="{ row }">
{{ row.execution.nextRunAt ? formatDateTime(row.execution.nextRunAt) : '-' }}
</template>
</el-table-column>
<el-table-column label="Runs" width="100">
<template #default="{ row }">
{{ row.execution.runCount }}
</template>
</el-table-column>
<el-table-column label="Actions" width="280" fixed="right">
<template #default="{ row }">
<el-button
size="small"
@click="viewHistory(row)"
icon="Clock"
>
History
</el-button>
<el-button
size="small"
@click="editSchedule(row)"
icon="Edit"
>
Edit
</el-button>
<el-button
v-if="row.status === 'active' || row.status === 'scheduled'"
size="small"
type="warning"
@click="pauseSchedule(row)"
icon="VideoPause"
>
Pause
</el-button>
<el-button
v-else-if="row.status === 'paused'"
size="small"
type="success"
@click="resumeSchedule(row)"
icon="VideoPlay"
>
Resume
</el-button>
<el-button
size="small"
type="danger"
@click="deleteSchedule(row)"
icon="Delete"
>
Delete
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
style="margin-top: 20px"
/>
</el-card>
<!-- History Dialog -->
<el-dialog
title="Campaign Execution History"
v-model="showHistoryDialog"
width="800px"
>
<div v-if="selectedHistory">
<h3>{{ selectedHistory.campaign.name }}</h3>
<el-table :data="selectedHistory.executions" style="width: 100%">
<el-table-column
prop="runAt"
label="Run Time"
width="180"
>
<template #default="{ row }">
{{ formatDateTime(row.runAt) }}
</template>
</el-table-column>
<el-table-column prop="status" label="Status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="messagesSent" label="Messages Sent" width="120" />
<el-table-column prop="errors" label="Errors" width="100" />
<el-table-column prop="duration" label="Duration (ms)" width="120" />
</el-table>
</div>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import api from '@/api';
import { formatDateTime } from '@/utils/date';
export default {
name: 'ScheduledCampaigns',
setup() {
const router = useRouter();
const loading = ref(false);
const campaigns = ref([]);
const total = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const showHistoryDialog = ref(false);
const selectedHistory = ref(null);
const filters = reactive({
status: '',
type: ''
});
const statistics = reactive({
totalCampaigns: 0,
activeCampaigns: 0,
totalMessagesSent: 0,
avgDeliveryRate: 0
});
const loadSchedules = async () => {
loading.value = true;
try {
const params = {
...filters,
limit: pageSize.value,
skip: (currentPage.value - 1) * pageSize.value
};
const res = await api.scheduledCampaigns.getAll(params);
campaigns.value = res.data;
total.value = res.total || res.data.length;
} catch (error) {
ElMessage.error('Failed to load scheduled campaigns');
} finally {
loading.value = false;
}
};
const loadStatistics = async () => {
try {
const res = await api.scheduledCampaigns.getStatistics('30d');
Object.assign(statistics, res.data);
} catch (error) {
console.error('Failed to load statistics:', error);
}
};
const createSchedule = () => {
router.push('/campaigns/schedule/new');
};
const editSchedule = (campaign) => {
router.push(`/campaigns/schedule/${campaign._id}`);
};
const viewHistory = async (campaign) => {
try {
const res = await api.scheduledCampaigns.getHistory(campaign._id);
selectedHistory.value = res.data;
showHistoryDialog.value = true;
} catch (error) {
ElMessage.error('Failed to load history');
}
};
const pauseSchedule = async (campaign) => {
try {
await ElMessageBox.confirm(
'Are you sure you want to pause this scheduled campaign?',
'Confirm Pause',
{
confirmButtonText: 'Pause',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
await api.scheduledCampaigns.pause(campaign._id);
ElMessage.success('Campaign paused');
loadSchedules();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to pause campaign');
}
}
};
const resumeSchedule = async (campaign) => {
try {
await api.scheduledCampaigns.resume(campaign._id);
ElMessage.success('Campaign resumed');
loadSchedules();
} catch (error) {
ElMessage.error('Failed to resume campaign');
}
};
const deleteSchedule = async (campaign) => {
try {
await ElMessageBox.confirm(
'Are you sure you want to delete this scheduled campaign? This action cannot be undone.',
'Confirm Delete',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
await api.scheduledCampaigns.delete(campaign._id);
ElMessage.success('Campaign deleted');
loadSchedules();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete campaign');
}
}
};
const resetFilters = () => {
filters.status = '';
filters.type = '';
currentPage.value = 1;
loadSchedules();
};
const handleSizeChange = (val) => {
pageSize.value = val;
loadSchedules();
};
const handleCurrentChange = (val) => {
currentPage.value = val;
loadSchedules();
};
const getStatusColor = (status) => {
const colors = {
draft: 'info',
scheduled: 'warning',
active: 'success',
paused: 'warning',
completed: 'info',
cancelled: 'info',
failed: 'danger'
};
return colors[status] || 'info';
};
const getTypeColor = (type) => {
const colors = {
'one-time': 'primary',
'recurring': 'success',
'trigger-based': 'warning'
};
return colors[type] || 'info';
};
const getScheduleDescription = (campaign) => {
if (campaign.type === 'one-time') {
return `Once at ${formatDateTime(campaign.schedule.startDateTime)}`;
} else if (campaign.type === 'recurring') {
const recurring = campaign.schedule.recurring;
let desc = `${recurring.pattern}`;
if (recurring.pattern === 'custom') {
desc = `Every ${recurring.frequency.interval} ${recurring.frequency.unit}`;
}
desc += ` at ${recurring.timeOfDay} (${recurring.timezone})`;
return desc;
} else {
return 'Trigger-based';
}
};
onMounted(() => {
loadSchedules();
loadStatistics();
});
return {
loading,
campaigns,
total,
currentPage,
pageSize,
filters,
statistics,
showHistoryDialog,
selectedHistory,
loadSchedules,
createSchedule,
editSchedule,
viewHistory,
pauseSchedule,
resumeSchedule,
deleteSchedule,
resetFilters,
handleSizeChange,
handleCurrentChange,
getStatusColor,
getTypeColor,
getScheduleDescription,
formatDateTime
};
}
};
</script>
<style scoped>
.scheduled-campaigns {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
}
.filter-card {
margin-bottom: 20px;
}
.stats-row {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,563 @@
<template>
<div class="data-exchange">
<el-row :gutter="20">
<!-- Export Section -->
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<h3>
<el-icon><Download /></el-icon>
Export Data
</h3>
</div>
</template>
<el-form :model="exportForm" label-width="120px">
<el-form-item label="Data Type">
<el-select v-model="exportForm.entityType" placeholder="Select data type">
<el-option label="Users" value="users" />
<el-option label="Campaigns" value="campaigns" />
<el-option label="Messages" value="messages" />
<el-option label="Contacts" value="contacts" />
<el-option label="Analytics" value="analytics" />
</el-select>
</el-form-item>
<el-form-item label="Format">
<el-radio-group v-model="exportForm.format">
<el-radio-button value="csv">CSV</el-radio-button>
<el-radio-button value="json">JSON</el-radio-button>
<el-radio-button value="excel">Excel</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="Date Range" v-if="showDateRange">
<el-date-picker
v-model="exportForm.dateRange"
type="daterange"
range-separator="to"
start-placeholder="Start date"
end-placeholder="End date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="Filters">
<el-button size="small" @click="showFilterDialog = true">
<el-icon><Filter /></el-icon>
Add Filters
<el-badge :value="filterCount" v-if="filterCount > 0" />
</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleExport" :loading="exportLoading">
<el-icon><Download /></el-icon>
Export
</el-button>
<el-button @click="downloadTemplate">
<el-icon><Document /></el-icon>
Download Template
</el-button>
</el-form-item>
</el-form>
<!-- Export History -->
<el-divider>Recent Exports</el-divider>
<div class="export-history">
<el-empty v-if="exportHistory.length === 0" description="No recent exports" />
<div v-else>
<div v-for="item in exportHistory" :key="item.id" class="history-item">
<el-tag size="small">{{ item.entityType }}</el-tag>
<span class="ml-2">{{ item.count }} records</span>
<span class="ml-2 text-gray">{{ formatDate(item.date) }}</span>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- Import Section -->
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<h3>
<el-icon><Upload /></el-icon>
Import Data
</h3>
</div>
</template>
<el-form :model="importForm" label-width="120px">
<el-form-item label="Data Type">
<el-select v-model="importForm.entityType" placeholder="Select data type">
<el-option label="Users" value="users" />
<el-option label="Campaigns" value="campaigns" />
<el-option label="Messages" value="messages" />
<el-option label="Contacts" value="contacts" />
<el-option label="Analytics" value="analytics" />
</el-select>
</el-form-item>
<el-form-item label="File">
<el-upload
ref="uploadRef"
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv,.json,.xlsx,.xls"
drag
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
Drop file here or <em>click to upload</em>
</div>
<template #tip>
<div class="el-upload__tip">
Supported formats: CSV, JSON, Excel (.xlsx, .xls)
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="Options">
<el-checkbox v-model="importForm.updateExisting">
Update existing records
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="handleValidate"
:disabled="!selectedFile"
:loading="validateLoading"
>
<el-icon><CircleCheck /></el-icon>
Validate
</el-button>
<el-button
type="success"
@click="handleImport"
:disabled="!selectedFile || !isValidated"
:loading="importLoading"
>
<el-icon><Upload /></el-icon>
Import
</el-button>
</el-form-item>
</el-form>
<!-- Validation Results -->
<div v-if="validationResult" class="validation-result">
<el-alert
:title="validationResult.success ? 'Validation Passed' : 'Validation Failed'"
:type="validationResult.success ? 'success' : 'error'"
:closable="false"
show-icon
>
<div>
<p>Total Records: {{ validationResult.totalRecords }}</p>
<p>Valid Records: {{ validationResult.validRecords }}</p>
<p v-if="validationResult.invalidRecords > 0">
Invalid Records: {{ validationResult.invalidRecords }}
</p>
</div>
</el-alert>
<!-- Validation Errors -->
<div v-if="validationResult.errors && validationResult.errors.length > 0" class="mt-3">
<h4>Validation Errors:</h4>
<el-table :data="validationResult.errors" max-height="300">
<el-table-column prop="row" label="Row" width="80" />
<el-table-column prop="errors" label="Errors">
<template #default="{ row }">
<ul class="error-list">
<li v-for="(error, index) in row.errors" :key="index">
{{ error }}
</li>
</ul>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- Import Results -->
<div v-if="importResult" class="import-result mt-3">
<el-alert
:title="importResult.success ? 'Import Completed' : 'Import Failed'"
:type="importResult.success ? 'success' : 'error'"
:closable="true"
show-icon
@close="importResult = null"
>
<div>
<p v-if="importResult.created">Created: {{ importResult.created }} records</p>
<p v-if="importResult.updated">Updated: {{ importResult.updated }} records</p>
<p v-if="importResult.skipped">Skipped: {{ importResult.skipped }} records</p>
<p v-if="importResult.errors && importResult.errors.length > 0">
Errors: {{ importResult.errors.length }} records
</p>
</div>
</el-alert>
</div>
</el-card>
</el-col>
</el-row>
<!-- Filter Dialog -->
<el-dialog v-model="showFilterDialog" title="Export Filters" width="500px">
<el-form :model="filters" label-width="100px">
<div v-if="exportForm.entityType === 'users'">
<el-form-item label="Role">
<el-select v-model="filters.role" clearable>
<el-option label="Admin" value="admin" />
<el-option label="Manager" value="manager" />
<el-option label="User" value="user" />
</el-select>
</el-form-item>
<el-form-item label="Status">
<el-select v-model="filters.status" clearable>
<el-option label="Active" value="active" />
<el-option label="Inactive" value="inactive" />
<el-option label="Banned" value="banned" />
</el-select>
</el-form-item>
</div>
<div v-else-if="exportForm.entityType === 'campaigns'">
<el-form-item label="Status">
<el-select v-model="filters.status" clearable>
<el-option label="Draft" value="draft" />
<el-option label="Active" value="active" />
<el-option label="Paused" value="paused" />
<el-option label="Completed" value="completed" />
</el-select>
</el-form-item>
</div>
<div v-else-if="exportForm.entityType === 'messages'">
<el-form-item label="Status">
<el-select v-model="filters.status" clearable>
<el-option label="Pending" value="pending" />
<el-option label="Sent" value="sent" />
<el-option label="Delivered" value="delivered" />
<el-option label="Failed" value="failed" />
<el-option label="Read" value="read" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="showFilterDialog = false">Cancel</el-button>
<el-button type="primary" @click="applyFilters">Apply</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
Download,
Upload,
UploadFilled,
Document,
Filter,
CircleCheck
} from '@element-plus/icons-vue'
import api from '@/api'
import dayjs from 'dayjs'
// State
const exportForm = reactive({
entityType: '',
format: 'csv',
dateRange: []
})
const importForm = reactive({
entityType: '',
updateExisting: false
})
const filters = reactive({
role: '',
status: ''
})
const exportLoading = ref(false)
const importLoading = ref(false)
const validateLoading = ref(false)
const showFilterDialog = ref(false)
const selectedFile = ref(null)
const uploadRef = ref()
const validationResult = ref(null)
const importResult = ref(null)
const isValidated = ref(false)
const exportHistory = ref([])
// Computed
const showDateRange = computed(() => {
return ['campaigns', 'messages', 'analytics'].includes(exportForm.entityType)
})
const filterCount = computed(() => {
return Object.values(filters).filter(v => v).length
})
// Methods
const handleExport = async () => {
if (!exportForm.entityType) {
ElMessage.warning('Please select a data type')
return
}
exportLoading.value = true
try {
const params = {
format: exportForm.format,
...filters
}
if (exportForm.dateRange?.length === 2) {
params.startDate = exportForm.dateRange[0]
params.endDate = exportForm.dateRange[1]
}
const response = await api.get(
`/api/v1/data-exchange/export/${exportForm.entityType}`,
{
params,
responseType: 'blob'
}
)
// Create download link
const blob = new Blob([response.data])
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
// Get filename from Content-Disposition header
const contentDisposition = response.headers['content-disposition']
const fileNameMatch = contentDisposition?.match(/filename="(.+)"/)
const fileName = fileNameMatch ? fileNameMatch[1] : `export.${exportForm.format}`
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
ElMessage.success('Export completed successfully')
// Add to history
exportHistory.value.unshift({
id: Date.now(),
entityType: exportForm.entityType,
count: 'N/A', // Would need to parse from response headers
date: new Date()
})
} catch (error) {
console.error('Export failed:', error)
ElMessage.error('Export failed')
} finally {
exportLoading.value = false
}
}
const downloadTemplate = async () => {
if (!exportForm.entityType) {
ElMessage.warning('Please select a data type')
return
}
try {
const response = await api.get(
`/api/v1/data-exchange/templates/${exportForm.entityType}`,
{
params: { format: exportForm.format },
responseType: 'blob'
}
)
// Create download link
const blob = new Blob([response.data])
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `${exportForm.entityType}-template.${exportForm.format}`)
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
ElMessage.success('Template downloaded successfully')
} catch (error) {
console.error('Download template failed:', error)
ElMessage.error('Failed to download template')
}
}
const handleFileChange = (file) => {
selectedFile.value = file.raw
isValidated.value = false
validationResult.value = null
importResult.value = null
}
const handleValidate = async () => {
if (!importForm.entityType) {
ElMessage.warning('Please select a data type')
return
}
if (!selectedFile.value) {
ElMessage.warning('Please select a file')
return
}
validateLoading.value = true
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await api.post(
`/api/v1/data-exchange/validate/${importForm.entityType}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
)
validationResult.value = response.data
isValidated.value = response.data.success
if (response.data.success) {
ElMessage.success('Validation passed')
} else {
ElMessage.warning('Validation failed. Please fix the errors and try again.')
}
} catch (error) {
console.error('Validation failed:', error)
ElMessage.error('Validation failed')
} finally {
validateLoading.value = false
}
}
const handleImport = async () => {
if (!isValidated.value) {
ElMessage.warning('Please validate the file first')
return
}
importLoading.value = true
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append('updateExisting', importForm.updateExisting)
const response = await api.post(
`/api/v1/data-exchange/import/${importForm.entityType}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
)
importResult.value = response.data
if (response.data.success) {
ElMessage.success('Import completed successfully')
// Reset form
uploadRef.value?.clearFiles()
selectedFile.value = null
isValidated.value = false
validationResult.value = null
} else {
ElMessage.error('Import failed')
}
} catch (error) {
console.error('Import failed:', error)
ElMessage.error('Import failed')
} finally {
importLoading.value = false
}
}
const applyFilters = () => {
showFilterDialog.value = false
ElMessage.success('Filters applied')
}
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm')
}
</script>
<style lang="scss" scoped>
.data-exchange {
padding: 20px;
.card-header {
h3 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
}
.export-history {
.history-item {
padding: 8px 0;
border-bottom: 1px solid #ebeef5;
&:last-child {
border-bottom: none;
}
}
}
.validation-result,
.import-result {
margin-top: 20px;
}
.error-list {
margin: 0;
padding-left: 20px;
li {
color: #f56c6c;
font-size: 12px;
}
}
.ml-2 {
margin-left: 8px;
}
.mt-3 {
margin-top: 12px;
}
.text-gray {
color: #909399;
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,657 @@
<template>
<div class="translation-manager">
<div class="page-header">
<h1>Translation Manager</h1>
<div class="header-actions">
<el-select
v-model="selectedLanguage"
@change="loadTranslations"
style="width: 200px; margin-right: 10px"
>
<el-option
v-for="lang in languages"
:key="lang.code"
:label="`${lang.flag} ${lang.name} (${lang.completion}%)`"
:value="lang.code"
>
<span style="float: left">{{ lang.flag }} {{ lang.name }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">
{{ lang.completion }}%
</span>
</el-option>
</el-select>
<el-button @click="showImportDialog = true">
<el-icon><Upload /></el-icon>
Import
</el-button>
<el-button @click="exportTranslations">
<el-icon><Download /></el-icon>
Export
</el-button>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
Add Translation
</el-button>
</div>
</div>
<el-card>
<div class="filter-bar">
<el-input
v-model="searchQuery"
placeholder="Search translations..."
prefix-icon="Search"
clearable
style="width: 300px"
/>
<div class="filter-actions">
<el-select v-model="namespaceFilter" placeholder="All Namespaces" clearable style="width: 200px">
<el-option label="Translation" value="translation" />
<el-option label="Validation" value="validation" />
<el-option label="Error" value="error" />
</el-select>
<el-select v-model="statusFilter" placeholder="All Status" clearable style="width: 150px">
<el-option label="Verified" value="verified" />
<el-option label="Unverified" value="unverified" />
<el-option label="Auto-translated" value="auto" />
</el-select>
<el-button @click="showMissing = !showMissing" :type="showMissing ? 'primary' : ''">
<el-icon><Warning /></el-icon>
Missing Only
</el-button>
</div>
</div>
<el-table
:data="filteredTranslations"
v-loading="loading"
style="width: 100%"
>
<el-table-column prop="key" label="Key" min-width="200">
<template #default="{ row }">
<code>{{ row.key }}</code>
</template>
</el-table-column>
<el-table-column prop="value" label="Translation" min-width="300">
<template #default="{ row }">
<div v-if="editingId === row._id" class="edit-cell">
<el-input
v-model="editingValue"
type="textarea"
:rows="2"
@keyup.enter.ctrl="saveEdit(row)"
/>
<div class="edit-actions">
<el-button size="small" type="primary" @click="saveEdit(row)">Save</el-button>
<el-button size="small" @click="cancelEdit">Cancel</el-button>
</div>
</div>
<div v-else class="translation-value" @click="startEdit(row)">
{{ row.value || '(missing)' }}
<el-icon v-if="!row.value" style="color: #F56C6C"><Warning /></el-icon>
</div>
</template>
</el-table-column>
<el-table-column prop="metadata.source" label="Source" width="120">
<template #default="{ row }">
<el-tag v-if="row.metadata?.source === 'auto'" type="warning" size="small">
Auto
</el-tag>
<el-tag v-else-if="row.metadata?.source === 'import'" type="info" size="small">
Import
</el-tag>
<el-tag v-else size="small">Manual</el-tag>
</template>
</el-table-column>
<el-table-column prop="metadata.verified" label="Status" width="120">
<template #default="{ row }">
<el-tag v-if="row.metadata?.verified" type="success" size="small">
Verified
</el-tag>
<el-tag v-else type="info" size="small">
Unverified
</el-tag>
</template>
</el-table-column>
<el-table-column prop="usage.count" label="Usage" width="100">
<template #default="{ row }">
{{ row.usage?.count || 0 }}
</template>
</el-table-column>
<el-table-column label="Actions" width="200" fixed="right">
<template #default="{ row }">
<el-button
v-if="!row.metadata?.verified"
size="small"
@click="verifyTranslation(row)"
>
<el-icon><CircleCheck /></el-icon>
Verify
</el-button>
<el-button
v-if="row.metadata?.source === 'auto'"
size="small"
@click="retranslate(row)"
>
<el-icon><Refresh /></el-icon>
Retranslate
</el-button>
<el-button
size="small"
type="danger"
:icon="Delete"
@click="deleteTranslation(row)"
/>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[20, 50, 100, 200]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadTranslations"
@current-change="loadTranslations"
/>
</div>
</el-card>
<!-- Add Translation Dialog -->
<el-dialog v-model="showAddDialog" title="Add Translation" width="600px">
<el-form :model="newTranslation" label-width="120px">
<el-form-item label="Key" required>
<el-input v-model="newTranslation.key" placeholder="e.g., welcome.message" />
</el-form-item>
<el-form-item label="Value" required>
<el-input
v-model="newTranslation.value"
type="textarea"
:rows="3"
placeholder="Translation text"
/>
</el-form-item>
<el-form-item label="Namespace">
<el-select v-model="newTranslation.namespace">
<el-option label="Translation" value="translation" />
<el-option label="Validation" value="validation" />
<el-option label="Error" value="error" />
</el-select>
</el-form-item>
<el-form-item label="Context">
<el-input v-model="newTranslation.context" placeholder="Optional context" />
</el-form-item>
<el-form-item label="Notes">
<el-input
v-model="newTranslation.metadata.notes"
type="textarea"
placeholder="Optional notes"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="addTranslation">Add</el-button>
</template>
</el-dialog>
<!-- Import Dialog -->
<el-dialog v-model="showImportDialog" title="Import Translations" width="600px">
<el-form>
<el-form-item label="Format">
<el-radio-group v-model="importFormat">
<el-radio label="json">JSON</el-radio>
<el-radio label="csv">CSV</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="File">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".json,.csv"
:on-change="handleFileChange"
>
<el-button slot="trigger">Select File</el-button>
</el-upload>
</el-form-item>
<el-form-item label="Options">
<el-checkbox v-model="importOptions.overwrite">
Overwrite existing translations
</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showImportDialog = false">Cancel</el-button>
<el-button type="primary" @click="importTranslations" :loading="importing">
Import
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Upload, Download, Search, Warning, CircleCheck,
Refresh, Delete
} from '@element-plus/icons-vue'
import api from '@/api'
const loading = ref(false)
const importing = ref(false)
const languages = ref([])
const translations = ref([])
const selectedLanguage = ref('en')
const searchQuery = ref('')
const namespaceFilter = ref('')
const statusFilter = ref('')
const showMissing = ref(false)
const showAddDialog = ref(false)
const showImportDialog = ref(false)
const editingId = ref(null)
const editingValue = ref('')
const pagination = reactive({
page: 1,
limit: 50,
total: 0
})
const newTranslation = reactive({
key: '',
value: '',
namespace: 'translation',
context: '',
metadata: {
notes: ''
}
})
const importFormat = ref('json')
const importFile = ref(null)
const importOptions = reactive({
overwrite: false
})
const filteredTranslations = computed(() => {
let filtered = translations.value;
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(t =>
t.key.toLowerCase().includes(query) ||
(t.value && t.value.toLowerCase().includes(query))
);
}
if (namespaceFilter.value) {
filtered = filtered.filter(t => t.namespace === namespaceFilter.value);
}
if (statusFilter.value) {
switch (statusFilter.value) {
case 'verified':
filtered = filtered.filter(t => t.metadata?.verified);
break;
case 'unverified':
filtered = filtered.filter(t => !t.metadata?.verified);
break;
case 'auto':
filtered = filtered.filter(t => t.metadata?.source === 'auto');
break;
}
}
if (showMissing.value) {
filtered = filtered.filter(t => !t.value);
}
return filtered;
});
const loadLanguages = async () => {
try {
const response = await api.get('/api/v1/languages/enabled');
languages.value = response.data.languages;
} catch (error) {
ElMessage.error('Failed to load languages');
}
};
const loadTranslations = async () => {
loading.value = true;
try {
const response = await api.get('/api/v1/translations', {
params: {
language: selectedLanguage.value,
namespace: namespaceFilter.value || undefined
}
});
translations.value = response.data.translations;
pagination.total = response.data.total;
// Also load missing translations
if (showMissing.value) {
const missingResponse = await api.get('/api/v1/translations/missing', {
params: { language: selectedLanguage.value }
});
// Add missing keys as empty translations
const missingTranslations = missingResponse.data.missingKeys.map(key => ({
key,
value: '',
language: selectedLanguage.value,
namespace: 'translation',
_id: `missing-${key}`
}));
translations.value = [...translations.value, ...missingTranslations];
}
} catch (error) {
ElMessage.error('Failed to load translations');
} finally {
loading.value = false;
}
};
const startEdit = (translation) => {
editingId.value = translation._id;
editingValue.value = translation.value || '';
};
const cancelEdit = () => {
editingId.value = null;
editingValue.value = '';
};
const saveEdit = async (translation) => {
try {
await api.post('/api/v1/translations', {
key: translation.key,
language: selectedLanguage.value,
value: editingValue.value,
namespace: translation.namespace,
context: translation.context
});
translation.value = editingValue.value;
cancelEdit();
ElMessage.success('Translation updated');
} catch (error) {
ElMessage.error('Failed to update translation');
}
};
const addTranslation = async () => {
if (!newTranslation.key || !newTranslation.value) {
ElMessage.warning('Key and value are required');
return;
}
try {
await api.post('/api/v1/translations', {
...newTranslation,
language: selectedLanguage.value
});
ElMessage.success('Translation added');
showAddDialog.value = false;
loadTranslations();
// Reset form
Object.assign(newTranslation, {
key: '',
value: '',
namespace: 'translation',
context: '',
metadata: { notes: '' }
});
} catch (error) {
ElMessage.error('Failed to add translation');
}
};
const verifyTranslation = async (translation) => {
try {
await api.put(`/api/v1/translations/${translation._id}/verify`);
translation.metadata = { ...translation.metadata, verified: true };
ElMessage.success('Translation verified');
} catch (error) {
ElMessage.error('Failed to verify translation');
}
};
const retranslate = async (translation) => {
try {
// Get source translation
const sourceResponse = await api.get('/api/v1/translations', {
params: {
key: translation.key,
language: 'en',
namespace: translation.namespace
}
});
if (!sourceResponse.data.translation) {
ElMessage.error('Source translation not found');
return;
}
// Auto-translate
const translateResponse = await api.post('/api/v1/translations/translate', {
text: sourceResponse.data.translation.value,
sourceLang: 'en',
targetLang: selectedLanguage.value
});
// Update translation
await api.post('/api/v1/translations', {
key: translation.key,
language: selectedLanguage.value,
value: translateResponse.data.translation.translated,
namespace: translation.namespace,
context: translation.context,
metadata: { source: 'auto' }
});
translation.value = translateResponse.data.translation.translated;
ElMessage.success('Translation updated');
} catch (error) {
ElMessage.error('Failed to retranslate');
}
};
const deleteTranslation = async (translation) => {
await ElMessageBox.confirm(
'Are you sure you want to delete this translation?',
'Delete Translation',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
try {
await api.delete(`/api/v1/translations/${translation._id}`);
ElMessage.success('Translation deleted');
loadTranslations();
} catch (error) {
ElMessage.error('Failed to delete translation');
}
};
const exportTranslations = async () => {
try {
const response = await api.get('/api/v1/translations/export', {
params: {
language: selectedLanguage.value,
format: 'json'
},
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `translations_${selectedLanguage.value}.json`);
document.body.appendChild(link);
link.click();
link.remove();
} catch (error) {
ElMessage.error('Failed to export translations');
}
};
const handleFileChange = (file) => {
importFile.value = file.raw;
};
const importTranslations = async () => {
if (!importFile.value) {
ElMessage.warning('Please select a file');
return;
}
importing.value = true;
try {
const content = await importFile.value.text();
const translations = importFormat.value === 'json'
? JSON.parse(content)
: parseCSV(content);
const response = await api.post('/api/v1/translations/bulk', {
translations,
language: selectedLanguage.value,
overwrite: importOptions.overwrite
});
ElMessage.success(
`Imported: ${response.data.results.imported}, Skipped: ${response.data.results.skipped}`
);
showImportDialog.value = false;
loadTranslations();
} catch (error) {
ElMessage.error('Failed to import translations');
} finally {
importing.value = false;
}
};
const parseCSV = (content) => {
const lines = content.split('\n');
const translations = {};
for (let i = 1; i < lines.length; i++) {
const match = lines[i].match(/^"([^"]+)","([^"]+)"$/);
if (match) {
translations[match[1]] = match[2];
}
}
return translations;
};
watch(selectedLanguage, () => {
loadTranslations();
});
onMounted(() => {
loadLanguages();
loadTranslations();
});
</script>
<style lang="scss" scoped>
.translation-manager {
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h1 {
margin: 0;
font-size: 24px;
}
.header-actions {
display: flex;
align-items: center;
}
}
.filter-bar {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
.filter-actions {
display: flex;
gap: 10px;
}
}
.translation-value {
cursor: pointer;
padding: 4px;
border-radius: 4px;
&:hover {
background: #f5f7fa;
}
}
.edit-cell {
.edit-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
}
code {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
color: #409eff;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,832 @@
<template>
<div class="template-list">
<div class="page-header">
<h1>Message Templates</h1>
<div class="header-actions">
<el-button @click="initializeCategories" plain>
<el-icon><FolderAdd /></el-icon>
Initialize Categories
</el-button>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
Create Template
</el-button>
</div>
</div>
<el-row :gutter="20">
<!-- Categories Sidebar -->
<el-col :span="6">
<el-card class="categories-card">
<template #header>
<div class="card-header">
<h3>Categories</h3>
<el-button size="small" @click="showCategoryDialog = true" :icon="Plus" circle />
</div>
</template>
<el-tree
:data="categories"
:props="{ label: 'name', children: 'subcategories' }"
@node-click="selectCategory"
:highlight-current="true"
node-key="id"
:default-expand-all="true"
>
<template #default="{ node, data }">
<span class="category-node">
<el-icon :style="{ color: data.color }">
<Folder />
</el-icon>
<span>{{ data.name }}</span>
<el-badge v-if="data.templateCount" :value="data.templateCount" />
</span>
</template>
</el-tree>
</el-card>
</el-col>
<!-- Templates List -->
<el-col :span="18">
<el-card>
<div class="filter-bar">
<el-input
v-model="searchQuery"
placeholder="Search templates..."
prefix-icon="Search"
clearable
style="width: 300px"
/>
<div class="filter-actions">
<el-select v-model="formatFilter" placeholder="All Formats" clearable style="width: 150px">
<el-option label="Text" value="text" />
<el-option label="HTML" value="html" />
<el-option label="Markdown" value="markdown" />
</el-select>
<el-select v-model="languageFilter" placeholder="All Languages" clearable style="width: 150px">
<el-option label="English" value="en" />
<el-option label="Spanish" value="es" />
<el-option label="French" value="fr" />
<el-option label="German" value="de" />
<el-option label="Chinese" value="zh" />
</el-select>
</div>
</div>
<el-table :data="templates" v-loading="loading" style="width: 100%">
<el-table-column prop="name" label="Name" min-width="200">
<template #default="{ row }">
<div class="template-name">
<span>{{ row.name }}</span>
<el-tag v-if="!row.isActive" type="info" size="small">Inactive</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="format" label="Format" width="100">
<template #default="{ row }">
<el-tag :type="getFormatType(row.format)">{{ row.format.toUpperCase() }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="language" label="Language" width="100">
<template #default="{ row }">
<span>{{ getLanguageName(row.language) }}</span>
</template>
</el-table-column>
<el-table-column prop="variables" label="Variables" width="150">
<template #default="{ row }">
<el-tooltip v-if="row.variables.length > 0" placement="top">
<template #content>
<div v-for="variable in row.variables" :key="variable.name">
{{ variable.name }} ({{ variable.type }})
</div>
</template>
<el-tag>{{ row.variables.length }} variables</el-tag>
</el-tooltip>
<span v-else class="text-gray">No variables</span>
</template>
</el-table-column>
<el-table-column prop="usageCount" label="Usage" width="100">
<template #default="{ row }">
<span>{{ row.usageCount }} times</span>
</template>
</el-table-column>
<el-table-column label="Actions" width="250" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="previewTemplate(row)">
<el-icon><View /></el-icon>
Preview
</el-button>
<el-button size="small" @click="editTemplate(row)">
<el-icon><Edit /></el-icon>
Edit
</el-button>
<el-dropdown @command="(cmd) => handleCommand(cmd, row)">
<el-button size="small" :icon="More" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="duplicate">
<el-icon><CopyDocument /></el-icon>
Duplicate
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>
Delete
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadTemplates"
@current-change="loadTemplates"
/>
</div>
</el-card>
</el-col>
</el-row>
<!-- Create/Edit Template Dialog -->
<el-dialog
v-model="showCreateDialog"
:title="editingTemplate ? 'Edit Template' : 'Create Template'"
width="800px"
:close-on-click-modal="false"
>
<el-form :model="templateForm" label-width="120px" ref="templateFormRef">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="Name" prop="name" required>
<el-input v-model="templateForm.name" placeholder="Template name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Category" prop="category">
<el-select v-model="templateForm.category" placeholder="Select category">
<el-option
v-for="cat in flatCategories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Description" prop="description">
<el-input
v-model="templateForm.description"
type="textarea"
placeholder="Template description"
:rows="2"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="Format" prop="format">
<el-select v-model="templateForm.format">
<el-option label="Plain Text" value="text" />
<el-option label="HTML" value="html" />
<el-option label="Markdown" value="markdown" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Language" prop="language">
<el-select v-model="templateForm.language">
<el-option label="English" value="en" />
<el-option label="Spanish" value="es" />
<el-option label="French" value="fr" />
<el-option label="German" value="de" />
<el-option label="Chinese" value="zh" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Active" prop="isActive" v-if="editingTemplate">
<el-switch v-model="templateForm.isActive" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Content" prop="content" required>
<div class="template-editor">
<div class="editor-toolbar">
<el-button-group>
<el-button size="small" @click="insertVariable">
<el-icon><Coin /></el-icon>
Insert Variable
</el-button>
<el-button size="small" @click="showHelpersDialog = true">
<el-icon><QuestionFilled /></el-icon>
Helpers
</el-button>
<el-button size="small" @click="validateTemplate">
<el-icon><CircleCheck /></el-icon>
Validate
</el-button>
</el-button-group>
</div>
<el-input
v-model="templateForm.content"
type="textarea"
placeholder="Template content..."
:rows="10"
@blur="detectVariables"
/>
</div>
</el-form-item>
<el-form-item label="Variables">
<div class="variables-list">
<div v-for="(variable, index) in templateForm.variables" :key="index" class="variable-item">
<el-input v-model="variable.name" placeholder="Variable name" style="width: 150px" disabled />
<el-select v-model="variable.type" style="width: 120px">
<el-option label="String" value="string" />
<el-option label="Number" value="number" />
<el-option label="Date" value="date" />
<el-option label="Email" value="email" />
<el-option label="Phone" value="phone" />
<el-option label="URL" value="url" />
</el-select>
<el-checkbox v-model="variable.required">Required</el-checkbox>
<el-input v-model="variable.defaultValue" placeholder="Default value" style="width: 150px" />
</div>
</div>
</el-form-item>
<el-form-item label="Tags">
<el-select
v-model="templateForm.tags"
multiple
filterable
allow-create
placeholder="Add tags"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">Cancel</el-button>
<el-button type="primary" @click="saveTemplate" :loading="saving">
{{ editingTemplate ? 'Update' : 'Create' }}
</el-button>
</template>
</el-dialog>
<!-- Preview Dialog -->
<el-dialog v-model="showPreviewDialog" title="Template Preview" width="600px">
<div class="preview-controls">
<h4>Test Data</h4>
<el-button size="small" @click="loadSampleData">Load Sample Data</el-button>
</div>
<el-form :model="previewData" label-width="120px" class="preview-form">
<el-form-item
v-for="variable in previewingTemplate?.variables"
:key="variable.name"
:label="variable.name"
>
<el-input
v-model="previewData[variable.name]"
:placeholder="variable.description || variable.name"
/>
</el-form-item>
</el-form>
<el-divider />
<div class="preview-result">
<h4>Preview</h4>
<div class="preview-content" v-html="previewContent"></div>
</div>
<template #footer>
<el-button @click="showPreviewDialog = false">Close</el-button>
<el-button type="primary" @click="updatePreview">Update Preview</el-button>
</template>
</el-dialog>
<!-- Category Dialog -->
<el-dialog v-model="showCategoryDialog" title="Create Category" width="500px">
<el-form :model="categoryForm" label-width="100px">
<el-form-item label="Name" required>
<el-input v-model="categoryForm.name" placeholder="Category name" />
</el-form-item>
<el-form-item label="Description">
<el-input v-model="categoryForm.description" placeholder="Category description" />
</el-form-item>
<el-form-item label="Color">
<el-color-picker v-model="categoryForm.color" />
</el-form-item>
<el-form-item label="Parent">
<el-select v-model="categoryForm.parentCategory" placeholder="Select parent category" clearable>
<el-option
v-for="cat in flatCategories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCategoryDialog = false">Cancel</el-button>
<el-button type="primary" @click="saveCategory" :loading="savingCategory">
Create
</el-button>
</template>
</el-dialog>
<!-- Helpers Dialog -->
<el-dialog v-model="showHelpersDialog" title="Template Helpers" width="700px">
<el-tabs>
<el-tab-pane label="Variables">
<VariableHelp />
</el-tab-pane>
<el-tab-pane label="Helpers">
<HelperReference />
</el-tab-pane>
</el-tabs>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import api from '@/api'
import VariableHelp from '@/components/templates/VariableHelp.vue'
import HelperReference from '@/components/templates/HelperReference.vue'
import {
Plus, Search, Edit, Delete, View, More, CopyDocument,
Folder, FolderAdd, Coin, QuestionFilled, CircleCheck
} from '@element-plus/icons-vue'
const authStore = useAuthStore()
const loading = ref(false)
const saving = ref(false)
const savingCategory = ref(false)
const templates = ref([])
const categories = ref([])
const showCreateDialog = ref(false)
const showPreviewDialog = ref(false)
const showCategoryDialog = ref(false)
const showHelpersDialog = ref(false)
const editingTemplate = ref(null)
const previewingTemplate = ref(null)
const searchQuery = ref('')
const formatFilter = ref('')
const languageFilter = ref('')
const selectedCategory = ref(null)
const pagination = reactive({
page: 1,
limit: 20,
total: 0
})
const templateForm = reactive({
name: '',
description: '',
category: '',
format: 'text',
language: 'en',
content: '',
variables: [],
tags: [],
isActive: true
})
const categoryForm = reactive({
name: '',
description: '',
color: '#409EFF',
parentCategory: null
})
const previewData = ref({})
const previewContent = ref('')
const flatCategories = computed(() => {
const flat = []
const flatten = (cats, level = 0) => {
cats.forEach(cat => {
flat.push({ ...cat, level })
if (cat.subcategories) {
flatten(cat.subcategories, level + 1)
}
})
}
flatten(categories.value)
return flat
})
const loadTemplates = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
limit: pagination.limit
}
if (searchQuery.value) {
params.search = searchQuery.value
}
if (formatFilter.value) {
params.format = formatFilter.value
}
if (languageFilter.value) {
params.language = languageFilter.value
}
if (selectedCategory.value) {
params.category = selectedCategory.value
}
const response = await api.get('/api/v1/templates', { params })
templates.value = response.data.templates
pagination.total = response.data.pagination.total
} catch (error) {
ElMessage.error('Failed to load templates')
} finally {
loading.value = false
}
}
const loadCategories = async () => {
try {
const response = await api.get('/api/v1/template-categories?includeCount=true')
categories.value = response.data.categories
} catch (error) {
console.error('Failed to load categories:', error)
}
}
const initializeCategories = async () => {
try {
await api.post('/api/v1/template-categories/initialize')
ElMessage.success('Default categories created')
loadCategories()
} catch (error) {
ElMessage.error('Failed to initialize categories')
}
}
const selectCategory = (category) => {
selectedCategory.value = category.id
loadTemplates()
}
const saveTemplate = async () => {
if (!templateForm.name || !templateForm.content) {
ElMessage.warning('Please fill in all required fields')
return
}
saving.value = true
try {
if (editingTemplate.value) {
await api.put(`/api/v1/templates/${editingTemplate.value.id}`, templateForm)
ElMessage.success('Template updated successfully')
} else {
await api.post('/api/v1/templates', templateForm)
ElMessage.success('Template created successfully')
}
showCreateDialog.value = false
loadTemplates()
} catch (error) {
ElMessage.error(error.response?.data?.error || 'Failed to save template')
} finally {
saving.value = false
}
}
const editTemplate = (template) => {
editingTemplate.value = template
Object.assign(templateForm, {
name: template.name,
description: template.description || '',
category: template.category?.id || '',
format: template.format,
language: template.language,
content: template.content,
variables: template.variables || [],
tags: template.tags || [],
isActive: template.isActive
})
showCreateDialog.value = true
}
const previewTemplate = async (template) => {
previewingTemplate.value = template
previewData.value = {}
// Initialize with default values
template.variables.forEach(variable => {
previewData.value[variable.name] = variable.defaultValue || ''
})
showPreviewDialog.value = true
updatePreview()
}
const updatePreview = async () => {
try {
const response = await api.post('/api/v1/templates/render', {
templateId: previewingTemplate.value.id,
data: previewData.value,
preview: true
})
previewContent.value = response.data.content
} catch (error) {
previewContent.value = `<div style="color: red;">Preview Error: ${error.response?.data?.error || error.message}</div>`
}
}
const loadSampleData = async () => {
try {
const response = await api.get('/api/v1/template-variables/sample-data')
Object.assign(previewData.value, response.data.sampleData)
updatePreview()
} catch (error) {
ElMessage.error('Failed to load sample data')
}
}
const duplicateTemplate = async (template) => {
try {
await api.post(`/api/v1/templates/${template.id}/duplicate`)
ElMessage.success('Template duplicated successfully')
loadTemplates()
} catch (error) {
ElMessage.error('Failed to duplicate template')
}
}
const deleteTemplate = async (template) => {
await ElMessageBox.confirm(
`Are you sure you want to delete template "${template.name}"?`,
'Delete Template',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
try {
await api.delete(`/api/v1/templates/${template.id}`)
ElMessage.success('Template deleted successfully')
loadTemplates()
} catch (error) {
ElMessage.error('Failed to delete template')
}
}
const handleCommand = (command, template) => {
switch (command) {
case 'duplicate':
duplicateTemplate(template)
break
case 'delete':
deleteTemplate(template)
break
}
}
const insertVariable = () => {
ElMessage.info('Select a variable from the available list to insert')
// TODO: Show variable selection dialog
}
const validateTemplate = async () => {
try {
const response = await api.post('/api/v1/templates/validate', {
content: templateForm.content
})
if (response.data.valid) {
ElMessage.success('Template syntax is valid')
} else {
ElMessage.error('Template syntax errors: ' + response.data.errors[0].message)
}
} catch (error) {
ElMessage.error('Failed to validate template')
}
}
const detectVariables = () => {
// This will be handled by the backend
// Variables are auto-detected when saving
}
const saveCategory = async () => {
if (!categoryForm.name) {
ElMessage.warning('Please enter a category name')
return
}
savingCategory.value = true
try {
await api.post('/api/v1/template-categories', categoryForm)
ElMessage.success('Category created successfully')
showCategoryDialog.value = false
loadCategories()
// Reset form
Object.assign(categoryForm, {
name: '',
description: '',
color: '#409EFF',
parentCategory: null
})
} catch (error) {
ElMessage.error(error.response?.data?.error || 'Failed to create category')
} finally {
savingCategory.value = false
}
}
const getFormatType = (format) => {
const types = {
text: '',
html: 'warning',
markdown: 'success'
}
return types[format] || ''
}
const getLanguageName = (code) => {
const languages = {
en: 'English',
es: 'Spanish',
fr: 'French',
de: 'German',
zh: 'Chinese'
}
return languages[code] || code
}
onMounted(() => {
loadTemplates()
loadCategories()
})
</script>
<style lang="scss" scoped>
.template-list {
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h1 {
margin: 0;
font-size: 24px;
}
.header-actions {
display: flex;
gap: 10px;
}
}
.categories-card {
height: calc(100vh - 160px);
overflow-y: auto;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
font-size: 16px;
}
}
.category-node {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
}
.filter-bar {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
.filter-actions {
display: flex;
gap: 10px;
}
}
.template-name {
display: flex;
align-items: center;
gap: 8px;
}
.template-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
.editor-toolbar {
background: #f5f7fa;
padding: 8px;
border-bottom: 1px solid #dcdfe6;
}
:deep(textarea) {
border: none;
border-radius: 0;
font-family: 'Courier New', monospace;
}
}
.variables-list {
display: flex;
flex-direction: column;
gap: 10px;
.variable-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
}
}
.preview-controls {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
h4 {
margin: 0;
}
}
.preview-form {
max-height: 300px;
overflow-y: auto;
}
.preview-result {
h4 {
margin-bottom: 10px;
}
.preview-content {
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
min-height: 100px;
white-space: pre-wrap;
}
}
.text-gray {
color: #909399;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,629 @@
<template>
<div class="tenant-list">
<div class="header">
<h1>{{ t('tenant.list.title') }}</h1>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon class="mr-2"><Plus /></el-icon>
{{ t('tenant.list.create') }}
</el-button>
</div>
<!-- Filters -->
<el-card class="mb-4">
<el-form :inline="true" :model="filters">
<el-form-item :label="t('tenant.list.search')">
<el-input
v-model="filters.search"
:placeholder="t('tenant.list.searchPlaceholder')"
clearable
@clear="fetchTenants"
@keyup.enter="fetchTenants"
/>
</el-form-item>
<el-form-item :label="t('tenant.list.status')">
<el-select
v-model="filters.status"
clearable
@change="fetchTenants"
>
<el-option label="All" value="" />
<el-option label="Active" value="active" />
<el-option label="Trial" value="trial" />
<el-option label="Suspended" value="suspended" />
<el-option label="Inactive" value="inactive" />
</el-select>
</el-form-item>
<el-form-item :label="t('tenant.list.plan')">
<el-select
v-model="filters.plan"
clearable
@change="fetchTenants"
>
<el-option label="All" value="" />
<el-option label="Free" value="free" />
<el-option label="Starter" value="starter" />
<el-option label="Professional" value="professional" />
<el-option label="Enterprise" value="enterprise" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="fetchTenants">
{{ t('common.search') }}
</el-button>
<el-button @click="resetFilters">
{{ t('common.reset') }}
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Tenants Table -->
<el-table
v-loading="loading"
:data="tenants"
stripe
style="width: 100%"
>
<el-table-column
prop="name"
:label="t('tenant.name')"
min-width="200"
>
<template #default="{ row }">
<div>
<div class="font-medium">{{ row.name }}</div>
<div class="text-sm text-gray-500">{{ row.slug }}</div>
</div>
</template>
</el-table-column>
<el-table-column
prop="plan"
:label="t('tenant.plan')"
width="120"
>
<template #default="{ row }">
<el-tag size="small">{{ row.plan }}</el-tag>
</template>
</el-table-column>
<el-table-column
prop="status"
:label="t('tenant.status')"
width="120"
>
<template #default="{ row }">
<el-tag
size="small"
:type="getStatusType(row.status)"
>
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="owner.email"
:label="t('tenant.owner')"
min-width="200"
/>
<el-table-column
:label="t('tenant.usage.title')"
width="200"
>
<template #default="{ row }">
<div class="usage-mini">
<div class="usage-item">
<span>{{ t('tenant.usage.users') }}:</span>
<span>{{ row.usage.users }} / {{ formatLimit(row.limits.users) }}</span>
</div>
<div class="usage-item">
<span>{{ t('tenant.usage.messages') }}:</span>
<span>{{ row.usage.messagesThisMonth }} / {{ formatLimit(row.limits.messagesPerMonth) }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
:label="t('tenant.createdAt')"
width="150"
:formatter="dateFormatter"
/>
<el-table-column
:label="t('common.actions')"
width="200"
fixed="right"
>
<template #default="{ row }">
<el-button
size="small"
@click="viewTenant(row)"
>
{{ t('common.view') }}
</el-button>
<el-button
size="small"
type="primary"
@click="editTenant(row)"
>
{{ t('common.edit') }}
</el-button>
<el-dropdown @command="(cmd) => handleCommand(cmd, row)">
<el-button size="small">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="suspend"
:disabled="row.status === 'suspended'"
>
{{ t('tenant.actions.suspend') }}
</el-dropdown-item>
<el-dropdown-item
command="activate"
:disabled="row.status === 'active'"
>
{{ t('tenant.actions.activate') }}
</el-dropdown-item>
<el-dropdown-item
command="login"
divided
>
{{ t('tenant.actions.loginAs') }}
</el-dropdown-item>
<el-dropdown-item
command="delete"
divided
class="text-red-600"
>
{{ t('tenant.actions.delete') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<!-- Pagination -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchTenants"
@current-change="fetchTenants"
/>
</div>
<!-- Edit Dialog -->
<el-dialog
v-model="showEditDialog"
:title="t('tenant.edit.title')"
width="600px"
>
<el-form
ref="editForm"
:model="editingTenant"
:rules="editRules"
label-width="120px"
>
<el-form-item :label="t('tenant.name')" prop="name">
<el-input v-model="editingTenant.name" />
</el-form-item>
<el-form-item :label="t('tenant.status')" prop="status">
<el-select v-model="editingTenant.status">
<el-option label="Active" value="active" />
<el-option label="Trial" value="trial" />
<el-option label="Suspended" value="suspended" />
<el-option label="Inactive" value="inactive" />
</el-select>
</el-form-item>
<el-form-item :label="t('tenant.plan')" prop="plan">
<el-select v-model="editingTenant.plan">
<el-option label="Free" value="free" />
<el-option label="Starter" value="starter" />
<el-option label="Professional" value="professional" />
<el-option label="Enterprise" value="enterprise" />
</el-select>
</el-form-item>
<el-form-item :label="t('tenant.limits.title')">
<div class="limits-editor">
<div class="limit-row" v-for="(value, key) in editingTenant.limits" :key="key">
<span>{{ getLimitLabel(key) }}:</span>
<el-input-number
v-model="editingTenant.limits[key]"
:min="-1"
:step="getStep(key)"
controls-position="right"
/>
<el-checkbox
v-model="unlimitedLimits[key]"
@change="toggleUnlimited(key)"
class="ml-2"
>
{{ t('tenant.unlimited') }}
</el-checkbox>
</div>
</div>
</el-form-item>
<el-form-item :label="t('tenant.features.title')">
<el-checkbox-group v-model="enabledFeatures">
<el-checkbox
v-for="(value, key) in editingTenant.features"
:key="key"
:label="key"
>
{{ getFeatureLabel(key) }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">
{{ t('common.cancel') }}
</el-button>
<el-button type="primary" @click="saveTenant" :loading="saving">
{{ t('common.save') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, More } from '@element-plus/icons-vue'
import { tenantApi } from '@/api/tenant'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
// Data
const loading = ref(false)
const saving = ref(false)
const tenants = ref([])
const showEditDialog = ref(false)
const showCreateDialog = ref(false)
const editingTenant = ref({})
const unlimitedLimits = ref({})
const enabledFeatures = ref([])
const filters = reactive({
search: '',
status: '',
plan: ''
})
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
pages: 0
})
const editRules = {
name: [
{ required: true, message: t('validation.required'), trigger: 'blur' }
],
status: [
{ required: true, message: t('validation.required'), trigger: 'change' }
],
plan: [
{ required: true, message: t('validation.required'), trigger: 'change' }
]
}
// Methods
const fetchTenants = async () => {
loading.value = true
try {
const { data } = await tenantApi.list({
page: pagination.page,
limit: pagination.limit,
...filters
})
tenants.value = data.tenants
pagination.total = data.pagination.total
pagination.pages = data.pagination.pages
} catch (error) {
ElMessage.error(t('tenant.error.fetchFailed'))
} finally {
loading.value = false
}
}
const resetFilters = () => {
filters.search = ''
filters.status = ''
filters.plan = ''
pagination.page = 1
fetchTenants()
}
const viewTenant = (tenant) => {
router.push({
name: 'TenantDetail',
params: { id: tenant._id }
})
}
const editTenant = (tenant) => {
editingTenant.value = JSON.parse(JSON.stringify(tenant))
// Set unlimited checkboxes
unlimitedLimits.value = {}
Object.keys(tenant.limits).forEach(key => {
unlimitedLimits.value[key] = tenant.limits[key] === -1
})
// Set enabled features
enabledFeatures.value = Object.keys(tenant.features).filter(key => tenant.features[key])
showEditDialog.value = true
}
const saveTenant = async () => {
try {
await editForm.value.validate()
saving.value = true
// Update features based on checkboxes
Object.keys(editingTenant.value.features).forEach(key => {
editingTenant.value.features[key] = enabledFeatures.value.includes(key)
})
await tenantApi.updateById(editingTenant.value._id, {
name: editingTenant.value.name,
status: editingTenant.value.status,
plan: editingTenant.value.plan,
limits: editingTenant.value.limits,
features: editingTenant.value.features
})
ElMessage.success(t('tenant.success.updated'))
showEditDialog.value = false
fetchTenants()
} catch (error) {
ElMessage.error(t('tenant.error.updateFailed'))
} finally {
saving.value = false
}
}
const handleCommand = async (command, tenant) => {
switch (command) {
case 'suspend':
await suspendTenant(tenant)
break
case 'activate':
await activateTenant(tenant)
break
case 'login':
await loginAsTenant(tenant)
break
case 'delete':
await deleteTenant(tenant)
break
}
}
const suspendTenant = async (tenant) => {
try {
await ElMessageBox.prompt(
t('tenant.actions.suspendPrompt'),
t('tenant.actions.suspend'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
inputPlaceholder: t('tenant.actions.reasonPlaceholder')
}
).then(async ({ value }) => {
await tenantApi.updateById(tenant._id, {
status: 'suspended',
metadata: { suspensionReason: value }
})
ElMessage.success(t('tenant.success.suspended'))
fetchTenants()
})
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(t('tenant.error.suspendFailed'))
}
}
}
const activateTenant = async (tenant) => {
try {
await tenantApi.updateById(tenant._id, {
status: 'active'
})
ElMessage.success(t('tenant.success.activated'))
fetchTenants()
} catch (error) {
ElMessage.error(t('tenant.error.activateFailed'))
}
}
const loginAsTenant = async (tenant) => {
// This would typically generate a special token for superadmin to login as tenant
window.open(`/dashboard?tenant=${tenant.slug}`, '_blank')
}
const deleteTenant = async (tenant) => {
try {
await ElMessageBox.confirm(
t('tenant.actions.deleteConfirm'),
t('tenant.actions.delete'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
)
await tenantApi.delete(tenant._id)
ElMessage.success(t('tenant.success.deleted'))
fetchTenants()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(t('tenant.error.deleteFailed'))
}
}
}
const toggleUnlimited = (key) => {
if (unlimitedLimits.value[key]) {
editingTenant.value.limits[key] = -1
} else {
// Set default values based on plan
const defaults = {
users: 10,
campaigns: 20,
messagesPerMonth: 5000,
telegramAccounts: 2,
storage: 5368709120 // 5GB
}
editingTenant.value.limits[key] = defaults[key] || 100
}
}
// Utilities
const getStatusType = (status) => {
const types = {
active: 'success',
trial: 'warning',
suspended: 'danger',
inactive: 'info'
}
return types[status] || 'info'
}
const formatLimit = (limit) => {
return limit === -1 ? '∞' : limit.toLocaleString()
}
const dateFormatter = (row, column, cellValue) => {
return new Date(cellValue).toLocaleDateString()
}
const getLimitLabel = (key) => {
const labels = {
users: t('tenant.limits.users'),
campaigns: t('tenant.limits.campaigns'),
messagesPerMonth: t('tenant.limits.messages'),
telegramAccounts: t('tenant.limits.accounts'),
storage: t('tenant.limits.storage'),
apiCallsPerHour: t('tenant.limits.apiCalls'),
webhooks: t('tenant.limits.webhooks')
}
return labels[key] || key
}
const getFeatureLabel = (key) => {
const labels = {
campaigns: t('tenant.features.campaigns'),
automation: t('tenant.features.automation'),
analytics: t('tenant.features.analytics'),
abTesting: t('tenant.features.abTesting'),
apiAccess: t('tenant.features.apiAccess'),
customReports: t('tenant.features.customReports'),
whiteLabel: t('tenant.features.whiteLabel'),
multiLanguage: t('tenant.features.multiLanguage'),
advancedSegmentation: t('tenant.features.advancedSegmentation'),
aiSuggestions: t('tenant.features.aiSuggestions')
}
return labels[key] || key
}
const getStep = (key) => {
if (key === 'storage') return 1073741824 // 1GB
if (key === 'messagesPerMonth') return 1000
return 1
}
// Lifecycle
onMounted(() => {
fetchTenants()
})
</script>
<style scoped>
.tenant-list {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.usage-mini {
font-size: 12px;
}
.usage-item {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.pagination {
margin-top: 24px;
text-align: right;
}
.limits-editor {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
background: #f9fafb;
}
.limit-row {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 12px;
}
.limit-row span {
min-width: 150px;
font-size: 14px;
}
.el-checkbox-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
</style>

View File

@@ -0,0 +1,619 @@
<template>
<div class="tenant-settings">
<el-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">{{ t('tenant.settings.title') }}</h2>
<el-tag :type="getStatusType(tenant.status)">
{{ tenant.status }}
</el-tag>
</div>
</template>
<el-form
ref="settingsForm"
:model="settings"
:rules="settingsRules"
label-width="140px"
>
<!-- Basic Information -->
<div class="section-title">{{ t('tenant.settings.basicInfo') }}</div>
<el-form-item :label="t('tenant.name')" prop="name">
<el-input
v-model="tenant.name"
:disabled="!isOwner"
@change="updateTenant"
/>
</el-form-item>
<el-form-item :label="t('tenant.slug')">
<el-input v-model="tenant.slug" disabled>
<template #append>
<el-button @click="copyToClipboard(tenant.slug)">
<el-icon><CopyDocument /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item :label="t('tenant.domain')">
<el-input
v-model="tenant.domain"
:disabled="!hasFeature('whiteLabel')"
placeholder="custom.domain.com"
@change="updateTenant"
/>
<template #help>
<div class="text-sm text-gray-500 mt-1">
{{ t('tenant.settings.domainHelp') }}
</div>
</template>
</el-form-item>
<!-- Settings -->
<div class="section-title">{{ t('tenant.settings.general') }}</div>
<el-form-item :label="t('tenant.settings.timezone')">
<el-select
v-model="settings.timezone"
@change="updateSettings"
>
<el-option
v-for="tz in timezones"
:key="tz.value"
:label="tz.label"
:value="tz.value"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('tenant.settings.language')">
<el-select
v-model="settings.language"
@change="updateSettings"
>
<el-option label="English" value="en" />
<el-option label="中文" value="zh" />
<el-option label="Español" value="es" />
<el-option label="Français" value="fr" />
</el-select>
</el-form-item>
<el-form-item :label="t('tenant.settings.dateFormat')">
<el-select
v-model="settings.dateFormat"
@change="updateSettings"
>
<el-option label="YYYY-MM-DD" value="YYYY-MM-DD" />
<el-option label="DD/MM/YYYY" value="DD/MM/YYYY" />
<el-option label="MM/DD/YYYY" value="MM/DD/YYYY" />
</el-select>
</el-form-item>
<el-form-item :label="t('tenant.settings.timeFormat')">
<el-radio-group
v-model="settings.timeFormat"
@change="updateSettings"
>
<el-radio label="24h">24-hour</el-radio>
<el-radio label="12h">12-hour</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('tenant.settings.currency')">
<el-select
v-model="settings.currency"
@change="updateSettings"
>
<el-option label="USD ($)" value="USD" />
<el-option label="EUR (€)" value="EUR" />
<el-option label="GBP (£)" value="GBP" />
<el-option label="CNY (¥)" value="CNY" />
</el-select>
</el-form-item>
<!-- Security Settings -->
<div class="section-title">{{ t('tenant.settings.security') }}</div>
<el-form-item :label="t('tenant.settings.allowSignup')">
<el-switch
v-model="settings.allowSignup"
@change="updateSettings"
/>
</el-form-item>
<el-form-item :label="t('tenant.settings.requireEmailVerification')">
<el-switch
v-model="settings.requireEmailVerification"
@change="updateSettings"
/>
</el-form-item>
<el-form-item :label="t('tenant.settings.twoFactorAuth')">
<el-switch
v-model="settings.twoFactorAuth"
@change="updateSettings"
/>
</el-form-item>
<el-form-item
v-if="hasFeature('apiAccess')"
:label="t('tenant.settings.ssoEnabled')"
>
<el-switch
v-model="settings.ssoEnabled"
@change="updateSettings"
/>
</el-form-item>
</el-form>
</el-card>
<!-- Branding Settings -->
<el-card
v-if="hasFeature('whiteLabel')"
class="mb-6"
>
<template #header>
<h2 class="text-xl font-semibold">{{ t('tenant.settings.branding') }}</h2>
</template>
<el-form
:model="branding"
label-width="140px"
>
<el-form-item :label="t('tenant.branding.logo')">
<el-upload
class="logo-uploader"
:action="`/api/v1/tenants/current/branding/logo`"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleLogoSuccess"
:before-upload="beforeLogoUpload"
>
<img
v-if="branding.logo"
:src="branding.logo"
class="logo"
>
<el-icon v-else class="logo-uploader-icon">
<Plus />
</el-icon>
</el-upload>
</el-form-item>
<el-form-item :label="t('tenant.branding.primaryColor')">
<el-color-picker
v-model="branding.primaryColor"
@change="updateBranding"
/>
</el-form-item>
<el-form-item :label="t('tenant.branding.secondaryColor')">
<el-color-picker
v-model="branding.secondaryColor"
@change="updateBranding"
/>
</el-form-item>
<el-form-item :label="t('tenant.branding.supportEmail')">
<el-input
v-model="branding.supportEmail"
type="email"
@change="updateBranding"
/>
</el-form-item>
<el-form-item :label="t('tenant.branding.supportUrl')">
<el-input
v-model="branding.supportUrl"
@change="updateBranding"
/>
</el-form-item>
<el-form-item :label="t('tenant.branding.customCss')">
<el-input
v-model="branding.customCss"
type="textarea"
:rows="6"
@change="updateBranding"
/>
</el-form-item>
</el-form>
</el-card>
<!-- Usage & Limits -->
<el-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">{{ t('tenant.usage.title') }}</h2>
<el-button
v-if="!isEnterprise"
type="primary"
size="small"
@click="showUpgradeDialog = true"
>
{{ t('tenant.upgrade') }}
</el-button>
</div>
</template>
<div class="usage-stats">
<div
v-for="(item, key) in usageItems"
:key="key"
class="usage-item"
>
<div class="usage-header">
<span class="usage-label">{{ item.label }}</span>
<span class="usage-value">
{{ formatUsage(usage[key], limits[key]) }}
</span>
</div>
<el-progress
:percentage="getUsagePercentage(usage[key], limits[key])"
:status="getUsageStatus(usage[key], limits[key])"
/>
</div>
</div>
<div class="billing-info mt-6">
<el-descriptions :column="2" border>
<el-descriptions-item :label="t('tenant.plan')">
<el-tag>{{ tenant.plan }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('tenant.billing.nextBillingDate')">
{{ formatDate(tenant.billing?.nextBillingDate) || '-' }}
</el-descriptions-item>
<el-descriptions-item
v-if="tenant.trial?.endDate"
:label="t('tenant.trial.endDate')"
>
{{ formatDate(tenant.trial.endDate) }}
</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
<!-- Compliance Settings -->
<el-card>
<template #header>
<h2 class="text-xl font-semibold">{{ t('tenant.compliance.title') }}</h2>
</template>
<el-form
:model="compliance"
label-width="180px"
>
<el-form-item :label="t('tenant.compliance.gdprEnabled')">
<el-switch
v-model="compliance.gdprEnabled"
@change="updateCompliance"
/>
</el-form-item>
<el-form-item :label="t('tenant.compliance.dataRetentionDays')">
<el-input-number
v-model="compliance.dataRetentionDays"
:min="30"
:max="3650"
@change="updateCompliance"
/>
</el-form-item>
<el-form-item :label="t('tenant.compliance.auditLogRetentionDays')">
<el-input-number
v-model="compliance.auditLogRetentionDays"
:min="90"
:max="3650"
@change="updateCompliance"
/>
</el-form-item>
<el-form-item :label="t('tenant.compliance.ipWhitelist')">
<el-select
v-model="compliance.ipWhitelist"
multiple
filterable
allow-create
default-first-option
:placeholder="t('tenant.compliance.ipWhitelistPlaceholder')"
@change="updateCompliance"
>
<el-option
v-for="ip in compliance.ipWhitelist"
:key="ip"
:label="ip"
:value="ip"
/>
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- Upgrade Dialog -->
<el-dialog
v-model="showUpgradeDialog"
:title="t('tenant.upgrade.title')"
width="600px"
>
<TenantUpgrade
:current-plan="tenant.plan"
@upgrade="handleUpgrade"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { CopyDocument, Plus } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { tenantApi } from '@/api/tenant'
import TenantUpgrade from './TenantUpgrade.vue'
const { t } = useI18n()
const authStore = useAuthStore()
// Data
const tenant = ref({})
const settings = ref({})
const branding = ref({})
const compliance = ref({})
const usage = ref({})
const limits = ref({})
const showUpgradeDialog = ref(false)
// Computed
const isOwner = computed(() => {
return authStore.user?.role === 'admin' || authStore.user?.role === 'superadmin'
})
const isEnterprise = computed(() => tenant.value.plan === 'enterprise')
const hasFeature = (feature) => {
return tenant.value.features?.[feature] || false
}
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${authStore.token}`
}))
const usageItems = {
users: { label: t('tenant.usage.users') },
campaigns: { label: t('tenant.usage.campaigns') },
messagesThisMonth: { label: t('tenant.usage.messages') },
storageUsed: { label: t('tenant.usage.storage') },
telegramAccounts: { label: t('tenant.usage.telegramAccounts') }
}
const timezones = [
{ value: 'UTC', label: 'UTC' },
{ value: 'America/New_York', label: 'Eastern Time' },
{ value: 'America/Chicago', label: 'Central Time' },
{ value: 'America/Los_Angeles', label: 'Pacific Time' },
{ value: 'Europe/London', label: 'London' },
{ value: 'Europe/Paris', label: 'Paris' },
{ value: 'Asia/Shanghai', label: 'Shanghai' },
{ value: 'Asia/Tokyo', label: 'Tokyo' }
]
// Methods
const loadTenantData = async () => {
try {
const { data } = await tenantApi.getCurrent()
tenant.value = data.tenant
settings.value = data.tenant.settings || {}
branding.value = data.tenant.branding || {}
compliance.value = data.tenant.compliance || {}
// Load usage
const usageData = await tenantApi.getUsage()
usage.value = usageData.data.usage
limits.value = usageData.data.limits
} catch (error) {
ElMessage.error(t('tenant.error.loadFailed'))
}
}
const updateTenant = async () => {
try {
await tenantApi.update({
name: tenant.value.name,
domain: tenant.value.domain
})
ElMessage.success(t('tenant.success.updated'))
} catch (error) {
ElMessage.error(t('tenant.error.updateFailed'))
}
}
const updateSettings = async () => {
try {
await tenantApi.updateSettings(settings.value)
ElMessage.success(t('tenant.success.settingsUpdated'))
} catch (error) {
ElMessage.error(t('tenant.error.updateFailed'))
}
}
const updateBranding = async () => {
try {
await tenantApi.updateBranding(branding.value)
ElMessage.success(t('tenant.success.brandingUpdated'))
// Apply custom CSS if changed
if (branding.value.customCss) {
applyCustomCss(branding.value.customCss)
}
} catch (error) {
ElMessage.error(t('tenant.error.updateFailed'))
}
}
const updateCompliance = async () => {
try {
await tenantApi.updateCompliance(compliance.value)
ElMessage.success(t('tenant.success.complianceUpdated'))
} catch (error) {
ElMessage.error(t('tenant.error.updateFailed'))
}
}
const handleLogoSuccess = (response) => {
branding.value.logo = response.data.url
updateBranding()
}
const beforeLogoUpload = (file) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error(t('tenant.error.logoMustBeImage'))
}
if (!isLt2M) {
ElMessage.error(t('tenant.error.logoTooLarge'))
}
return isImage && isLt2M
}
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
ElMessage.success(t('common.copied'))
}
const getStatusType = (status) => {
const types = {
active: 'success',
trial: 'warning',
suspended: 'danger',
inactive: 'info'
}
return types[status] || 'info'
}
const formatUsage = (used, limit) => {
if (limit === -1) return `${used} / ∞`
return `${used} / ${limit}`
}
const getUsagePercentage = (used, limit) => {
if (limit === -1) return 0
return Math.min(100, (used / limit) * 100)
}
const getUsageStatus = (used, limit) => {
if (limit === -1) return 'success'
const percentage = (used / limit) * 100
if (percentage >= 90) return 'exception'
if (percentage >= 75) return 'warning'
return 'success'
}
const formatDate = (date) => {
if (!date) return null
return new Date(date).toLocaleDateString()
}
const handleUpgrade = async (plan) => {
showUpgradeDialog.value = false
// Redirect to billing page or handle upgrade
window.location.href = `/billing/upgrade?plan=${plan}`
}
const applyCustomCss = (css) => {
let styleEl = document.getElementById('tenant-custom-css')
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = 'tenant-custom-css'
document.head.appendChild(styleEl)
}
styleEl.textContent = css
}
// Lifecycle
onMounted(() => {
loadTenantData()
})
</script>
<style scoped>
.tenant-settings {
max-width: 1200px;
margin: 0 auto;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin: 24px 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.usage-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.usage-item {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
}
.usage-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.usage-label {
font-size: 14px;
color: #6b7280;
}
.usage-value {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.logo-uploader {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
width: 178px;
height: 178px;
display: flex;
align-items: center;
justify-content: center;
}
.logo-uploader:hover {
border-color: #409eff;
}
.logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.logo-uploader-icon {
font-size: 28px;
color: #8c939d;
}
.billing-info {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,497 @@
<template>
<div class="tenant-upgrade">
<div class="plans-grid">
<div
v-for="plan in plans"
:key="plan.id"
class="plan-card"
:class="{
'current': plan.id === currentPlan,
'recommended': plan.recommended
}"
>
<div v-if="plan.recommended" class="recommended-badge">
{{ t('tenant.upgrade.recommended') }}
</div>
<h3 class="plan-name">{{ plan.name }}</h3>
<div class="plan-price">
<span class="currency">$</span>
<span class="amount">{{ plan.price }}</span>
<span class="period">/{{ t('tenant.upgrade.month') }}</span>
</div>
<div class="plan-description">
{{ plan.description }}
</div>
<ul class="plan-features">
<li v-for="feature in plan.features" :key="feature">
<el-icon class="check-icon"><Check /></el-icon>
{{ feature }}
</li>
</ul>
<div class="plan-limits">
<div class="limit-item" v-for="(limit, key) in plan.limits" :key="key">
<span class="limit-label">{{ getLimitLabel(key) }}:</span>
<span class="limit-value">{{ formatLimit(limit) }}</span>
</div>
</div>
<el-button
v-if="plan.id !== currentPlan"
type="primary"
size="large"
:disabled="isPlanDowngrade(plan.id)"
@click="selectPlan(plan)"
>
{{ isPlanDowngrade(plan.id) ? t('tenant.upgrade.downgrade') : t('tenant.upgrade.upgrade') }}
</el-button>
<el-button
v-else
size="large"
disabled
>
{{ t('tenant.upgrade.currentPlan') }}
</el-button>
</div>
</div>
<div class="enterprise-section" v-if="!plans.find(p => p.id === 'enterprise')">
<el-card>
<div class="enterprise-content">
<h3>{{ t('tenant.upgrade.enterprise.title') }}</h3>
<p>{{ t('tenant.upgrade.enterprise.description') }}</p>
<ul class="enterprise-features">
<li>{{ t('tenant.upgrade.enterprise.unlimited') }}</li>
<li>{{ t('tenant.upgrade.enterprise.sla') }}</li>
<li>{{ t('tenant.upgrade.enterprise.support') }}</li>
<li>{{ t('tenant.upgrade.enterprise.customization') }}</li>
</ul>
<el-button type="primary" @click="contactSales">
{{ t('tenant.upgrade.enterprise.contact') }}
</el-button>
</div>
</el-card>
</div>
<!-- Payment Method Selection -->
<el-dialog
v-model="showPaymentDialog"
:title="t('tenant.upgrade.payment.title')"
width="500px"
>
<el-form :model="paymentForm" label-width="120px">
<el-form-item :label="t('tenant.upgrade.payment.method')">
<el-radio-group v-model="paymentForm.method">
<el-radio label="card">{{ t('tenant.upgrade.payment.card') }}</el-radio>
<el-radio label="paypal">PayPal</el-radio>
<el-radio label="wire">{{ t('tenant.upgrade.payment.wire') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="paymentForm.method === 'card'"
:label="t('tenant.upgrade.payment.cardNumber')"
>
<el-input
v-model="paymentForm.cardNumber"
placeholder="1234 5678 9012 3456"
/>
</el-form-item>
<el-form-item :label="t('tenant.upgrade.payment.billingCycle')">
<el-radio-group v-model="paymentForm.billingCycle">
<el-radio label="monthly">{{ t('tenant.upgrade.payment.monthly') }}</el-radio>
<el-radio label="yearly">
{{ t('tenant.upgrade.payment.yearly') }}
<el-tag type="success" size="small" class="ml-2">
{{ t('tenant.upgrade.payment.save20') }}
</el-tag>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<div class="payment-summary">
<div class="summary-row">
<span>{{ selectedPlan.name }} {{ t('tenant.upgrade.plan') }}</span>
<span>${{ calculatePrice() }}</span>
</div>
<div class="summary-row" v-if="paymentForm.billingCycle === 'yearly'">
<span>{{ t('tenant.upgrade.payment.discount') }}</span>
<span>-${{ calculateDiscount() }}</span>
</div>
<div class="summary-row total">
<span>{{ t('tenant.upgrade.payment.total') }}</span>
<span>${{ calculateTotal() }}</span>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPaymentDialog = false">
{{ t('common.cancel') }}
</el-button>
<el-button type="primary" @click="confirmUpgrade" :loading="upgrading">
{{ t('tenant.upgrade.confirm') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Check } from '@element-plus/icons-vue'
const props = defineProps({
currentPlan: {
type: String,
required: true
}
})
const emit = defineEmits(['upgrade'])
const { t } = useI18n()
// Data
const showPaymentDialog = ref(false)
const selectedPlan = ref(null)
const upgrading = ref(false)
const paymentForm = ref({
method: 'card',
cardNumber: '',
billingCycle: 'monthly'
})
const plans = ref([
{
id: 'free',
name: t('tenant.plans.free'),
price: 0,
description: t('tenant.plans.freeDesc'),
features: [
t('tenant.features.users', { count: 5 }),
t('tenant.features.campaigns', { count: 10 }),
t('tenant.features.messages', { count: '1,000' }),
t('tenant.features.basicAnalytics'),
t('tenant.features.communitySupport')
],
limits: {
users: 5,
campaigns: 10,
messagesPerMonth: 1000,
telegramAccounts: 1,
storage: 1073741824
}
},
{
id: 'starter',
name: t('tenant.plans.starter'),
price: 29,
description: t('tenant.plans.starterDesc'),
recommended: true,
features: [
t('tenant.features.users', { count: 20 }),
t('tenant.features.campaigns', { count: 50 }),
t('tenant.features.messages', { count: '10,000' }),
t('tenant.features.automation'),
t('tenant.features.apiAccess'),
t('tenant.features.emailSupport')
],
limits: {
users: 20,
campaigns: 50,
messagesPerMonth: 10000,
telegramAccounts: 3,
storage: 5368709120
}
},
{
id: 'professional',
name: t('tenant.plans.professional'),
price: 99,
description: t('tenant.plans.professionalDesc'),
features: [
t('tenant.features.users', { count: 100 }),
t('tenant.features.campaigns', { count: 200 }),
t('tenant.features.messages', { count: '50,000' }),
t('tenant.features.abTesting'),
t('tenant.features.customReports'),
t('tenant.features.multiLanguage'),
t('tenant.features.prioritySupport')
],
limits: {
users: 100,
campaigns: 200,
messagesPerMonth: 50000,
telegramAccounts: 10,
storage: 21474836480
}
}
])
// Methods
const getLimitLabel = (key) => {
const labels = {
users: t('tenant.limits.users'),
campaigns: t('tenant.limits.campaigns'),
messagesPerMonth: t('tenant.limits.messages'),
telegramAccounts: t('tenant.limits.accounts'),
storage: t('tenant.limits.storage')
}
return labels[key] || key
}
const formatLimit = (limit) => {
if (limit === -1) return '∞'
if (typeof limit === 'number' && limit > 1000000) {
return `${(limit / 1073741824).toFixed(1)} GB`
}
return limit.toLocaleString()
}
const isPlanDowngrade = (planId) => {
const planOrder = ['free', 'starter', 'professional', 'enterprise']
const currentIndex = planOrder.indexOf(props.currentPlan)
const targetIndex = planOrder.indexOf(planId)
return targetIndex < currentIndex
}
const selectPlan = (plan) => {
selectedPlan.value = plan
showPaymentDialog.value = true
}
const calculatePrice = () => {
if (!selectedPlan.value) return 0
return selectedPlan.value.price * (paymentForm.value.billingCycle === 'yearly' ? 12 : 1)
}
const calculateDiscount = () => {
if (!selectedPlan.value || paymentForm.value.billingCycle !== 'yearly') return 0
return Math.round(selectedPlan.value.price * 12 * 0.2)
}
const calculateTotal = () => {
return calculatePrice() - calculateDiscount()
}
const confirmUpgrade = async () => {
upgrading.value = true
try {
// Here you would integrate with your payment processor
await new Promise(resolve => setTimeout(resolve, 2000))
ElMessage.success(t('tenant.upgrade.success'))
showPaymentDialog.value = false
emit('upgrade', selectedPlan.value.id)
} catch (error) {
ElMessage.error(t('tenant.upgrade.error'))
} finally {
upgrading.value = false
}
}
const contactSales = () => {
window.location.href = '/contact-sales'
}
</script>
<style scoped>
.tenant-upgrade {
padding: 20px;
}
.plans-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.plan-card {
position: relative;
padding: 32px;
border: 2px solid #e5e7eb;
border-radius: 12px;
background: white;
transition: all 0.3s;
}
.plan-card:hover {
border-color: #3b82f6;
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.plan-card.current {
border-color: #10b981;
background: #f0fdf4;
}
.plan-card.recommended {
border-color: #3b82f6;
transform: scale(1.05);
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.2);
}
.recommended-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: #3b82f6;
color: white;
padding: 4px 16px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.plan-name {
font-size: 24px;
font-weight: 700;
margin-bottom: 12px;
color: #1f2937;
}
.plan-price {
margin-bottom: 16px;
}
.currency {
font-size: 18px;
color: #6b7280;
}
.amount {
font-size: 40px;
font-weight: 700;
color: #1f2937;
}
.period {
font-size: 16px;
color: #6b7280;
}
.plan-description {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
line-height: 1.6;
}
.plan-features {
list-style: none;
padding: 0;
margin: 0 0 24px;
}
.plan-features li {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: #4b5563;
}
.check-icon {
color: #10b981;
margin-right: 8px;
}
.plan-limits {
margin-bottom: 24px;
padding: 16px;
background: #f9fafb;
border-radius: 8px;
}
.limit-item {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
}
.limit-label {
color: #6b7280;
}
.limit-value {
font-weight: 600;
color: #1f2937;
}
.enterprise-content {
text-align: center;
padding: 40px;
}
.enterprise-content h3 {
font-size: 28px;
margin-bottom: 16px;
color: #1f2937;
}
.enterprise-content p {
font-size: 16px;
color: #6b7280;
margin-bottom: 24px;
}
.enterprise-features {
list-style: none;
padding: 0;
margin: 0 0 32px;
display: inline-block;
text-align: left;
}
.enterprise-features li {
margin-bottom: 12px;
padding-left: 24px;
position: relative;
}
.enterprise-features li::before {
content: '✓';
position: absolute;
left: 0;
color: #10b981;
font-weight: bold;
}
.payment-summary {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
background: #f9fafb;
}
.summary-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.summary-row.total {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
font-weight: 600;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<div class="user-management">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="用户列表" name="users">
<UserList ref="userList" />
</el-tab-pane>
<el-tab-pane label="用户组" name="groups">
<GroupManagement ref="groupManagement" />
</el-tab-pane>
<el-tab-pane label="标签" name="tags">
<TagManagement ref="tagManagement" />
</el-tab-pane>
<el-tab-pane label="用户分段" name="segments">
<SegmentManagement ref="segmentManagement" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref } from 'vue';
import UserList from './components/UserList.vue';
import GroupManagement from './components/GroupManagement.vue';
import TagManagement from './components/TagManagement.vue';
import SegmentManagement from './components/SegmentManagement.vue';
const activeTab = ref('users');
const userList = ref();
const groupManagement = ref();
const tagManagement = ref();
const segmentManagement = ref();
const handleTabClick = (tab) => {
// Refresh data when switching tabs
switch (tab.props.name) {
case 'users':
userList.value?.refreshData();
break;
case 'groups':
groupManagement.value?.refreshData();
break;
case 'tags':
tagManagement.value?.refreshData();
break;
case 'segments':
segmentManagement.value?.refreshData();
break;
}
};
</script>
<style scoped>
.user-management {
padding: 20px;
}
.el-tabs {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="组名称" prop="name">
<el-input v-model="form.name" placeholder="请输入组名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入组描述"
/>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-radio-group v-model="form.type" @change="handleTypeChange">
<el-radio label="static">静态组</el-radio>
<el-radio label="dynamic">动态组</el-radio>
<el-radio label="smart">智能组</el-radio>
</el-radio-group>
<div class="type-hint">
<el-text v-if="form.type === 'static'" type="info">
手动添加或移除成员
</el-text>
<el-text v-else-if="form.type === 'dynamic'" type="info">
根据规则自动计算成员
</el-text>
<el-text v-else-if="form.type === 'smart'" type="info">
基于 AI 智能推荐成员
</el-text>
</div>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="form.color" />
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-select v-model="form.icon" placeholder="选择图标">
<el-option label="用户" value="users" />
<el-option label="星标" value="star" />
<el-option label="新用户" value="user-plus" />
<el-option label="时钟" value="user-clock" />
</el-select>
</el-form-item>
<!-- Dynamic group rules -->
<template v-if="form.type !== 'static'">
<el-divider>规则设置</el-divider>
<el-form-item label="规则逻辑">
<el-radio-group v-model="form.rules.logic">
<el-radio label="AND">满足所有条件</el-radio>
<el-radio label="OR">满足任一条件</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="条件规则">
<div
v-for="(criterion, index) in form.rules.criteria"
:key="index"
class="criterion-row"
>
<el-select
v-model="criterion.field"
placeholder="选择字段"
style="width: 150px"
>
<el-option label="状态" value="status" />
<el-option label="参与度分数" value="engagement.engagementScore" />
<el-option label="创建时间" value="createdAt" />
<el-option label="最后活跃时间" value="lastActiveAt" />
<el-option label="消息发送数" value="engagement.totalMessagesSent" />
<el-option label="消息已读数" value="engagement.totalMessagesRead" />
</el-select>
<el-select
v-model="criterion.operator"
placeholder="选择操作符"
style="width: 120px; margin-left: 10px"
>
<el-option label="等于" value="equals" />
<el-option label="不等于" value="not_equals" />
<el-option label="包含" value="contains" />
<el-option label="不包含" value="not_contains" />
<el-option label="大于" value="greater_than" />
<el-option label="小于" value="less_than" />
<el-option label="在...之中" value="in" />
<el-option label="不在...之中" value="not_in" />
</el-select>
<el-input
v-model="criterion.value"
placeholder="输入值"
style="width: 200px; margin-left: 10px"
/>
<el-button
link
type="danger"
@click="removeCriterion(index)"
style="margin-left: 10px"
>
删除
</el-button>
</div>
<el-button @click="addCriterion" style="margin-top: 10px">
添加条件
</el-button>
</el-form-item>
<el-form-item label="自动更新">
<el-switch v-model="form.rules.autoUpdate" />
</el-form-item>
<el-form-item
v-if="form.rules.autoUpdate"
label="更新频率"
>
<el-select v-model="form.rules.updateFrequency">
<el-option label="实时" value="realtime" />
<el-option label="每小时" value="hourly" />
<el-option label="每天" value="daily" />
<el-option label="每周" value="weekly" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="testRules" :loading="testing">
测试规则
</el-button>
<el-text v-if="testResult" type="info" style="margin-left: 10px">
匹配用户数: {{ testResult.totalCount }}
</el-text>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" @click="submitForm" :loading="loading">
{{ mode === 'create' ? '创建' : '更新' }}
</el-button>
<el-button @click="$emit('cancel')">取消</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive, watch } from 'vue';
import { ElMessage } from 'element-plus';
import api from '@/api';
const props = defineProps({
group: {
type: Object,
default: null
},
mode: {
type: String,
default: 'create' // create or edit
}
});
const emit = defineEmits(['success', 'cancel']);
const formRef = ref();
const loading = ref(false);
const testing = ref(false);
const testResult = ref(null);
const form = reactive({
name: '',
description: '',
type: 'static',
color: '#409EFF',
icon: 'users',
rules: {
criteria: [],
logic: 'AND',
autoUpdate: true,
updateFrequency: 'daily'
}
});
const rules = {
name: [
{ required: true, message: '请输入组名称', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择组类型', trigger: 'change' }
]
};
// Initialize form with group data if editing
if (props.mode === 'edit' && props.group) {
Object.assign(form, props.group);
}
const handleTypeChange = (value) => {
if (value === 'static') {
form.rules = {
criteria: [],
logic: 'AND',
autoUpdate: false,
updateFrequency: 'daily'
};
}
};
const addCriterion = () => {
form.rules.criteria.push({
field: '',
operator: '',
value: ''
});
};
const removeCriterion = (index) => {
form.rules.criteria.splice(index, 1);
};
const testRules = async () => {
if (form.rules.criteria.length === 0) {
ElMessage.warning('请先添加条件规则');
return;
}
testing.value = true;
try {
const response = await api.post('/api/v1/groups/test-rules', {
rules: form.rules
});
testResult.value = response.data;
ElMessage.success(`规则测试成功,匹配 ${response.data.totalCount} 个用户`);
} catch (error) {
ElMessage.error('规则测试失败');
} finally {
testing.value = false;
}
};
const submitForm = async () => {
await formRef.value.validate();
loading.value = true;
try {
if (props.mode === 'create') {
await api.post('/api/v1/groups', form);
ElMessage.success('用户组创建成功');
} else {
await api.put(`/api/v1/groups/${props.group._id}`, form);
ElMessage.success('用户组更新成功');
}
emit('success');
} catch (error) {
ElMessage.error(error.response?.data?.error || '操作失败');
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.type-hint {
margin-top: 5px;
}
.criterion-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.el-divider {
margin: 20px 0;
}
</style>

View File

@@ -0,0 +1,392 @@
<template>
<div class="group-management">
<!-- Actions -->
<div class="actions">
<el-button type="primary" @click="createGroup">
<el-icon><Plus /></el-icon>
创建用户组
</el-button>
<el-button @click="initializeDefaults" :loading="initializing">
初始化默认组
</el-button>
</div>
<!-- Group cards -->
<el-row :gutter="20">
<el-col
v-for="group in groups"
:key="group._id"
:xs="24"
:sm="12"
:md="8"
:lg="6"
>
<el-card class="group-card" :body-style="{ position: 'relative' }">
<div class="group-header">
<el-icon :size="24" :style="{ color: group.color }">
<component :is="getGroupIcon(group.icon)" />
</el-icon>
<h3>{{ group.name }}</h3>
<el-tag :type="getGroupType(group.type)" size="small">
{{ getGroupTypeText(group.type) }}
</el-tag>
</div>
<p class="group-description">{{ group.description || '暂无描述' }}</p>
<div class="group-stats">
<div class="stat">
<span class="stat-label">成员数</span>
<span class="stat-value">{{ group.memberCount }}</span>
</div>
<div class="stat" v-if="group.type !== 'static'">
<span class="stat-label">更新频率</span>
<span class="stat-value">{{ getUpdateFrequencyText(group.rules?.updateFrequency) }}</span>
</div>
</div>
<div class="group-actions">
<el-button link type="primary" @click="viewMembers(group)">
查看成员
</el-button>
<el-button link type="primary" @click="editGroup(group)">
编辑
</el-button>
<el-dropdown style="margin-left: 10px">
<el-button link type="primary">
更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="group.type !== 'static'"
@click="recalculateGroup(group)"
>
重新计算成员
</el-dropdown-item>
<el-dropdown-item @click="duplicateGroup(group)">
复制用户组
</el-dropdown-item>
<el-dropdown-item
divided
@click="deleteGroup(group)"
style="color: #f56c6c"
>
删除用户组
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-card>
</el-col>
</el-row>
<!-- Group stats -->
<el-card class="stats-card" v-if="stats">
<h3>用户组统计</h3>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="总用户组数" :value="stats.total" />
</el-col>
<el-col :span="6">
<el-statistic title="静态组" :value="stats.byType.static" />
</el-col>
<el-col :span="6">
<el-statistic title="动态组" :value="stats.byType.dynamic" />
</el-col>
<el-col :span="6">
<el-statistic title="总成员数" :value="stats.totalMembers" />
</el-col>
</el-row>
</el-card>
<!-- Group dialog -->
<el-dialog
v-model="showGroupDialog"
:title="groupDialogMode === 'create' ? '创建用户组' : '编辑用户组'"
width="600px"
>
<GroupForm
:group="currentGroup"
:mode="groupDialogMode"
@success="handleGroupSuccess"
@cancel="showGroupDialog = false"
/>
</el-dialog>
<!-- Members dialog -->
<el-dialog
v-model="showMembersDialog"
title="用户组成员"
width="800px"
>
<GroupMembers
v-if="currentGroup"
:group="currentGroup"
@update="fetchGroups"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, ArrowDown, UserFilled, Star, Timer, User } from '@element-plus/icons-vue';
import api from '@/api';
import GroupForm from './GroupForm.vue';
import GroupMembers from './GroupMembers.vue';
const groups = ref([]);
const stats = ref(null);
const loading = ref(false);
const initializing = ref(false);
// Dialogs
const showGroupDialog = ref(false);
const groupDialogMode = ref('create');
const currentGroup = ref(null);
const showMembersDialog = ref(false);
// Fetch groups
const fetchGroups = async () => {
loading.value = true;
try {
const [groupsRes, statsRes] = await Promise.all([
api.get('/api/v1/groups'),
api.get('/api/v1/groups-stats')
]);
groups.value = groupsRes.data.groups;
stats.value = statsRes.data;
} catch (error) {
ElMessage.error('获取用户组失败');
} finally {
loading.value = false;
}
};
// Group actions
const createGroup = () => {
currentGroup.value = null;
groupDialogMode.value = 'create';
showGroupDialog.value = true;
};
const editGroup = (group) => {
currentGroup.value = group;
groupDialogMode.value = 'edit';
showGroupDialog.value = true;
};
const viewMembers = (group) => {
currentGroup.value = group;
showMembersDialog.value = true;
};
const recalculateGroup = async (group) => {
try {
const response = await api.post(`/api/v1/groups/${group._id}/recalculate`);
ElMessage.success(`成员数已更新: ${response.data.memberCount}`);
fetchGroups();
} catch (error) {
ElMessage.error('重新计算失败');
}
};
const duplicateGroup = async (group) => {
try {
const newGroup = {
name: `${group.name} (副本)`,
description: group.description,
type: group.type,
color: group.color,
icon: group.icon,
rules: group.rules
};
await api.post('/api/v1/groups', newGroup);
ElMessage.success('用户组已复制');
fetchGroups();
} catch (error) {
ElMessage.error('复制失败');
}
};
const deleteGroup = async (group) => {
try {
await ElMessageBox.confirm(
`确定要删除用户组 "${group.name}" 吗?这将从所有用户中移除该组。`,
'删除用户组',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
);
await api.delete(`/api/v1/groups/${group._id}`);
ElMessage.success('用户组已删除');
fetchGroups();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const initializeDefaults = async () => {
initializing.value = true;
try {
await api.post('/api/v1/groups/initialize-defaults');
ElMessage.success('默认用户组已创建');
fetchGroups();
} catch (error) {
ElMessage.error('初始化失败');
} finally {
initializing.value = false;
}
};
// Event handlers
const handleGroupSuccess = () => {
showGroupDialog.value = false;
fetchGroups();
};
// Utility functions
const getGroupIcon = (icon) => {
const icons = {
users: UserFilled,
star: Star,
'user-plus': User,
'user-clock': Timer
};
return icons[icon] || UserFilled;
};
const getGroupType = (type) => {
const types = {
static: 'primary',
dynamic: 'success',
smart: 'warning'
};
return types[type] || 'info';
};
const getGroupTypeText = (type) => {
const texts = {
static: '静态',
dynamic: '动态',
smart: '智能'
};
return texts[type] || type;
};
const getUpdateFrequencyText = (frequency) => {
const texts = {
realtime: '实时',
hourly: '每小时',
daily: '每天',
weekly: '每周'
};
return texts[frequency] || frequency;
};
// Expose methods
const refreshData = () => {
fetchGroups();
};
defineExpose({
refreshData
});
onMounted(() => {
refreshData();
});
</script>
<style scoped>
.group-management {
height: 100%;
}
.actions {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.group-card {
margin-bottom: 20px;
cursor: pointer;
transition: all 0.3s;
}
.group-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.group-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.group-header h3 {
flex: 1;
margin: 0;
font-size: 16px;
}
.group-description {
color: #666;
font-size: 14px;
margin-bottom: 15px;
min-height: 40px;
}
.group-stats {
display: flex;
gap: 20px;
margin-bottom: 15px;
padding: 10px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 12px;
color: #999;
}
.stat-value {
font-size: 18px;
font-weight: bold;
color: #409eff;
}
.group-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.stats-card {
margin-top: 40px;
}
.stats-card h3 {
margin-bottom: 20px;
}
</style>

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