1
This commit is contained in:
1328
app/views/ConsolePage.vue
Normal file
1328
app/views/ConsolePage.vue
Normal file
File diff suppressed because it is too large
Load Diff
671
app/views/DeliveryPage.vue
Normal file
671
app/views/DeliveryPage.vue
Normal file
@@ -0,0 +1,671 @@
|
||||
<template>
|
||||
<div class="page-delivery">
|
||||
<h2 class="page-title">投递管理</h2>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-section" v-if="statistics">
|
||||
<Card class="stat-card">
|
||||
<template #content>
|
||||
<div class="stat-value">{{ statistics.totalCount || 0 }}</div>
|
||||
<div class="stat-label">总投递数</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="stat-card">
|
||||
<template #content>
|
||||
<div class="stat-value">{{ statistics.successCount || 0 }}</div>
|
||||
<div class="stat-label">成功数</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="stat-card">
|
||||
<template #content>
|
||||
<div class="stat-value">{{ statistics.interviewCount || 0 }}</div>
|
||||
<div class="stat-label">面试邀约</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="stat-card">
|
||||
<template #content>
|
||||
<div class="stat-value">{{ statistics.successRate || 0 }}%</div>
|
||||
<div class="stat-label">成功率</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-box">
|
||||
<Dropdown
|
||||
v-model="searchOption.platform"
|
||||
:options="platformOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="全部平台"
|
||||
class="filter-select"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<Dropdown
|
||||
v-model="searchOption.applyStatus"
|
||||
:options="applyStatusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="全部状态"
|
||||
class="filter-select"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<Dropdown
|
||||
v-model="searchOption.feedbackStatus"
|
||||
:options="feedbackStatusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="全部反馈"
|
||||
class="filter-select"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投递记录列表 -->
|
||||
<div class="table-section">
|
||||
<ProgressSpinner v-if="loading" />
|
||||
<div v-else-if="records.length === 0" class="empty">暂无投递记录</div>
|
||||
<DataTable v-else :value="records" tableStyle="min-width: 50rem">
|
||||
<Column field="jobTitle" header="岗位名称">
|
||||
<template #body="{ data }">
|
||||
{{ data.jobTitle || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="companyName" header="公司名称">
|
||||
<template #body="{ data }">
|
||||
{{ data.companyName || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="platform" header="平台">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="data.platform === 'boss' ? 'Boss直聘' : data.platform === 'liepin' ? '猎聘' : data.platform"
|
||||
severity="info"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="applyStatus" header="投递状态">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="getApplyStatusText(data.applyStatus)"
|
||||
:severity="getApplyStatusSeverity(data.applyStatus)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="feedbackStatus" header="反馈状态">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="getFeedbackStatusText(data.feedbackStatus)"
|
||||
:severity="getFeedbackStatusSeverity(data.feedbackStatus)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="applyTime" header="投递时间">
|
||||
<template #body="{ data }">
|
||||
{{ formatTime(data.applyTime) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作">
|
||||
<template #body="{ data }">
|
||||
<Button label="查看详情" size="small" @click="handleViewDetail(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Paginator
|
||||
v-if="total > 0"
|
||||
:rows="pageOption.pageSize"
|
||||
:totalRecords="total"
|
||||
:first="(currentPage - 1) * pageOption.pageSize"
|
||||
@page="onPageChange"
|
||||
/>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<Dialog
|
||||
v-model:visible="showDetail"
|
||||
modal
|
||||
header="投递详情"
|
||||
:style="{ width: '600px' }"
|
||||
@hide="closeDetail"
|
||||
>
|
||||
<div v-if="currentRecord" class="detail-content">
|
||||
<div class="detail-item">
|
||||
<label>岗位名称:</label>
|
||||
<span>{{ currentRecord.jobTitle || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>公司名称:</label>
|
||||
<span>{{ currentRecord.companyName || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>薪资范围:</label>
|
||||
<span>{{ currentRecord.salary || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>工作地点:</label>
|
||||
<span>{{ currentRecord.location || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>投递状态:</label>
|
||||
<span>{{ getApplyStatusText(currentRecord.applyStatus) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>反馈状态:</label>
|
||||
<span>{{ getFeedbackStatusText(currentRecord.feedbackStatus) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>投递时间:</label>
|
||||
<span>{{ formatTime(currentRecord.applyTime) }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentRecord.feedbackContent">
|
||||
<label>反馈内容:</label>
|
||||
<span>{{ currentRecord.feedbackContent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import applyRecordsAPI from '../api/apply_records.js';
|
||||
import { mapState } from 'vuex';
|
||||
import { Card, Dropdown, DataTable, Column, Tag, Button, Dialog, Paginator, ProgressSpinner } from '../components/PrimeVue';
|
||||
import logMixin from '../mixins/logMixin.js';
|
||||
|
||||
export default {
|
||||
name: 'DeliveryPage',
|
||||
mixins: [logMixin],
|
||||
components: {
|
||||
Card,
|
||||
Dropdown,
|
||||
DataTable,
|
||||
Column,
|
||||
Tag,
|
||||
Button,
|
||||
Dialog,
|
||||
Paginator,
|
||||
ProgressSpinner
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
records: [],
|
||||
statistics: null,
|
||||
searchOption: {
|
||||
key: 'jobTitle',
|
||||
value: '',
|
||||
platform: '',
|
||||
applyStatus: '',
|
||||
feedbackStatus: ''
|
||||
},
|
||||
platformOptions: [
|
||||
{ label: '全部平台', value: '' },
|
||||
{ label: 'Boss直聘', value: 'boss' },
|
||||
{ label: '猎聘', value: 'liepin' }
|
||||
],
|
||||
applyStatusOptions: [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '待投递', value: 'pending' },
|
||||
{ label: '投递中', value: 'applying' },
|
||||
{ label: '投递成功', value: 'success' },
|
||||
{ label: '投递失败', value: 'failed' }
|
||||
],
|
||||
feedbackStatusOptions: [
|
||||
{ label: '全部反馈', value: '' },
|
||||
{ label: '无反馈', value: 'none' },
|
||||
{ label: '已查看', value: 'viewed' },
|
||||
{ label: '感兴趣', value: 'interested' },
|
||||
{ label: '面试邀约', value: 'interview' }
|
||||
],
|
||||
pageOption: {
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
},
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
showDetail: false,
|
||||
currentRecord: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState('auth', ['snCode']),
|
||||
totalPages() {
|
||||
return Math.ceil(this.total / this.pageOption.pageSize);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadStatistics();
|
||||
this.loadRecords();
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 加载统计数据
|
||||
*/
|
||||
async loadStatistics() {
|
||||
try {
|
||||
// 获取 snCode 用于请求
|
||||
const snCode = this.$store?.state?.auth?.snCode;
|
||||
if (!snCode) {
|
||||
console.warn('未获取到设备SN码,无法加载统计数据');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await applyRecordsAPI.getStatistics(snCode);
|
||||
if (result && result.code === 0) {
|
||||
this.statistics = result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载投递记录
|
||||
*/
|
||||
async loadRecords() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = {
|
||||
sn_code: this.snCode,
|
||||
seachOption: this.searchOption,
|
||||
pageOption: this.pageOption
|
||||
};
|
||||
const result = await applyRecordsAPI.getList(params);
|
||||
if (result && result.code === 0) {
|
||||
this.records = result.data.rows || result.data.list || [];
|
||||
this.total = result.data.count || result.data.total || 0;
|
||||
this.currentPage = this.pageOption.page;
|
||||
console.log('[投递管理] 加载记录成功:', {
|
||||
recordsCount: this.records.length,
|
||||
total: this.total,
|
||||
currentPage: this.currentPage
|
||||
});
|
||||
} else {
|
||||
console.warn('[投递管理] 响应格式异常:', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载投递记录失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '加载投递记录失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 搜索
|
||||
*/
|
||||
handleSearch() {
|
||||
this.pageOption.page = 1;
|
||||
this.loadRecords();
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 分页切换
|
||||
*/
|
||||
handlePageChange(page) {
|
||||
this.pageOption.page = page;
|
||||
this.currentPage = page;
|
||||
this.loadRecords();
|
||||
},
|
||||
|
||||
/**
|
||||
* Paginator 分页事件
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.currentPage = Math.floor(event.first / this.pageOption.pageSize) + 1;
|
||||
this.pageOption.page = this.currentPage;
|
||||
this.loadRecords();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取投递状态严重程度(用于 Tag 组件)
|
||||
*/
|
||||
getApplyStatusSeverity(status) {
|
||||
const severityMap = {
|
||||
'pending': 'warning',
|
||||
'applying': 'info',
|
||||
'success': 'success',
|
||||
'failed': 'danger',
|
||||
'duplicate': 'secondary'
|
||||
};
|
||||
return severityMap[status] || 'secondary';
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取反馈状态严重程度(用于 Tag 组件)
|
||||
*/
|
||||
getFeedbackStatusSeverity(status) {
|
||||
const severityMap = {
|
||||
'none': 'secondary',
|
||||
'viewed': 'info',
|
||||
'interested': 'success',
|
||||
'not_suitable': 'danger',
|
||||
'interview': 'success'
|
||||
};
|
||||
return severityMap[status] || 'secondary';
|
||||
},
|
||||
|
||||
/**
|
||||
* 查看详情
|
||||
*/
|
||||
async handleViewDetail(record) {
|
||||
try {
|
||||
const result = await applyRecordsAPI.getDetail(record.id || record.applyId);
|
||||
if (result && result.code === 0) {
|
||||
this.currentRecord = result.data || record; // 如果没有返回详情,使用当前记录
|
||||
this.showDetail = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error);
|
||||
// 如果获取详情失败,直接使用当前记录显示
|
||||
this.currentRecord = record;
|
||||
this.showDetail = true;
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '获取详情失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 关闭详情
|
||||
*/
|
||||
closeDetail() {
|
||||
this.showDetail = false;
|
||||
this.currentRecord = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取投递状态文本
|
||||
*/
|
||||
getApplyStatusText(status) {
|
||||
const statusMap = {
|
||||
'pending': '待投递',
|
||||
'applying': '投递中',
|
||||
'success': '投递成功',
|
||||
'failed': '投递失败',
|
||||
'duplicate': '重复投递'
|
||||
};
|
||||
return statusMap[status] || status || '-';
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取反馈状态文本
|
||||
*/
|
||||
getFeedbackStatusText(status) {
|
||||
const statusMap = {
|
||||
'none': '无反馈',
|
||||
'viewed': '已查看',
|
||||
'interested': '感兴趣',
|
||||
'not_suitable': '不合适',
|
||||
'interview': '面试邀约'
|
||||
};
|
||||
return statusMap[status] || status || '-';
|
||||
},
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
formatTime(time) {
|
||||
if (!time) return '-';
|
||||
const date = new Date(time);
|
||||
return date.toLocaleString('zh-CN');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-delivery {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.platform-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
&.boss {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.liepin {
|
||||
background: #e8f5e9;
|
||||
color: #388e3c;
|
||||
}
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
|
||||
&.success {
|
||||
background: #e8f5e9;
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
&.interview {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.btn-primary {
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 15px;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
643
app/views/FeedbackPage.vue
Normal file
643
app/views/FeedbackPage.vue
Normal file
@@ -0,0 +1,643 @@
|
||||
<template>
|
||||
<div class="page-feedback">
|
||||
<h2 class="page-title">意见反馈</h2>
|
||||
|
||||
<!-- 反馈表单 -->
|
||||
<div class="feedback-form-section">
|
||||
<h3>提交反馈</h3>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label>反馈类型 <span class="required">*</span></label>
|
||||
<Dropdown
|
||||
v-model="formData.type"
|
||||
:options="typeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="请选择反馈类型"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>反馈内容 <span class="required">*</span></label>
|
||||
<Textarea
|
||||
v-model="formData.content"
|
||||
rows="6"
|
||||
placeholder="请详细描述您的问题或建议..."
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>联系方式(可选)</label>
|
||||
<InputText
|
||||
v-model="formData.contact"
|
||||
placeholder="请输入您的联系方式(手机号、邮箱等)"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
label="提交反馈"
|
||||
:disabled="submitting"
|
||||
:loading="submitting"
|
||||
/>
|
||||
<Button
|
||||
label="重置"
|
||||
severity="secondary"
|
||||
@click="handleReset"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 反馈历史 -->
|
||||
<div class="feedback-history-section">
|
||||
<h3>反馈历史</h3>
|
||||
<ProgressSpinner v-if="loading" />
|
||||
<div v-else-if="feedbacks.length === 0" class="empty">暂无反馈记录</div>
|
||||
<div v-else class="feedback-table-wrapper">
|
||||
<table class="feedback-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>反馈类型</th>
|
||||
<th>反馈内容</th>
|
||||
<th>状态</th>
|
||||
<th>提交时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="feedback in feedbacks" :key="feedback.id">
|
||||
<td>
|
||||
<Tag :value="getTypeText(feedback.type)" severity="info" />
|
||||
</td>
|
||||
<td class="content-cell">
|
||||
<div class="content-text">{{ feedback.content }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<Tag
|
||||
v-if="feedback.status"
|
||||
:value="getStatusText(feedback.status)"
|
||||
:severity="getStatusSeverity(feedback.status)"
|
||||
/>
|
||||
</td>
|
||||
<td class="time-cell">{{ formatTime(feedback.createTime) }}</td>
|
||||
<td>
|
||||
<Button
|
||||
label="查看详情"
|
||||
size="small"
|
||||
@click="handleViewDetail(feedback)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Paginator
|
||||
v-if="total > 0"
|
||||
:rows="pageSize"
|
||||
:totalRecords="total"
|
||||
:first="(currentPage - 1) * pageSize"
|
||||
@page="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<Dialog
|
||||
v-model:visible="showDetail"
|
||||
modal
|
||||
header="反馈详情"
|
||||
:style="{ width: '600px' }"
|
||||
@hide="closeDetail"
|
||||
>
|
||||
<div v-if="currentFeedback" class="detail-content">
|
||||
<div class="detail-item">
|
||||
<label>反馈类型:</label>
|
||||
<span>{{ getTypeText(currentFeedback.type) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>反馈内容:</label>
|
||||
<div class="detail-content">{{ currentFeedback.content }}</div>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentFeedback.contact">
|
||||
<label>联系方式:</label>
|
||||
<span>{{ currentFeedback.contact }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>处理状态:</label>
|
||||
<Tag
|
||||
:value="getStatusText(currentFeedback.status)"
|
||||
:severity="getStatusSeverity(currentFeedback.status)"
|
||||
/>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>提交时间:</label>
|
||||
<span>{{ formatTime(currentFeedback.createTime) }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentFeedback.reply_content">
|
||||
<label>回复内容:</label>
|
||||
<div class="detail-content">{{ currentFeedback.reply_content }}</div>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentFeedback.reply_time">
|
||||
<label>回复时间:</label>
|
||||
<span>{{ formatTime(currentFeedback.reply_time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<div v-if="showSuccess" class="success-message">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import feedbackAPI from '../api/feedback.js';
|
||||
import { mapState } from 'vuex';
|
||||
import { Dropdown, Textarea, InputText, Button, Card, Tag, Dialog, Paginator, ProgressSpinner } from '../components/PrimeVue';
|
||||
import logMixin from '../mixins/logMixin.js';
|
||||
|
||||
export default {
|
||||
name: 'FeedbackPage',
|
||||
mixins: [logMixin],
|
||||
components: {
|
||||
Dropdown,
|
||||
Textarea,
|
||||
InputText,
|
||||
Button,
|
||||
Card,
|
||||
Tag,
|
||||
Dialog,
|
||||
Paginator,
|
||||
ProgressSpinner
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
formData: {
|
||||
type: '',
|
||||
content: '',
|
||||
contact: ''
|
||||
},
|
||||
typeOptions: [
|
||||
{ label: 'Bug反馈', value: 'bug' },
|
||||
{ label: '功能建议', value: 'suggestion' },
|
||||
{ label: '使用问题', value: 'question' },
|
||||
{ label: '其他', value: 'other' }
|
||||
],
|
||||
submitting: false,
|
||||
feedbacks: [],
|
||||
loading: false,
|
||||
showSuccess: false,
|
||||
successMessage: '',
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showDetail: false,
|
||||
currentFeedback: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState('auth', ['snCode']),
|
||||
totalPages() {
|
||||
return Math.ceil(this.total / this.pageSize);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadFeedbacks();
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 提交反馈
|
||||
*/
|
||||
async handleSubmit() {
|
||||
if (!this.formData.type || !this.formData.content) {
|
||||
if (this.addLog) {
|
||||
this.addLog('warn', '请填写完整的反馈信息');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.snCode) {
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '请先登录');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
try {
|
||||
const result = await feedbackAPI.submit({
|
||||
...this.formData,
|
||||
sn_code: this.snCode
|
||||
});
|
||||
if (result && result.code === 0) {
|
||||
this.showSuccessMessage('反馈提交成功,感谢您的反馈!');
|
||||
this.handleReset();
|
||||
this.loadFeedbacks();
|
||||
} else {
|
||||
if (this.addLog) {
|
||||
this.addLog('error', result.message || '提交失败');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交反馈失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '提交反馈失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
handleReset() {
|
||||
this.formData = {
|
||||
type: '',
|
||||
content: '',
|
||||
contact: ''
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载反馈列表
|
||||
*/
|
||||
async loadFeedbacks() {
|
||||
if (!this.snCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await feedbackAPI.getList({
|
||||
sn_code: this.snCode,
|
||||
page: this.currentPage,
|
||||
pageSize: this.pageSize
|
||||
});
|
||||
if (result && result.code === 0) {
|
||||
this.feedbacks = result.data.rows || result.data.list || [];
|
||||
this.total = result.data.total || result.data.count || 0;
|
||||
} else {
|
||||
console.warn('[反馈管理] 响应格式异常:', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载反馈列表失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '加载反馈列表失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 查看详情
|
||||
*/
|
||||
async handleViewDetail(feedback) {
|
||||
try {
|
||||
const result = await feedbackAPI.getDetail(feedback.id);
|
||||
if (result && result.code === 0) {
|
||||
this.currentFeedback = result.data || feedback;
|
||||
this.showDetail = true;
|
||||
} else {
|
||||
// 如果获取详情失败,直接使用当前记录显示
|
||||
this.currentFeedback = feedback;
|
||||
this.showDetail = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error);
|
||||
// 如果获取详情失败,直接使用当前记录显示
|
||||
this.currentFeedback = feedback;
|
||||
this.showDetail = true;
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '获取详情失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 关闭详情
|
||||
*/
|
||||
closeDetail() {
|
||||
this.showDetail = false;
|
||||
this.currentFeedback = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 分页切换
|
||||
*/
|
||||
handlePageChange(page) {
|
||||
this.currentPage = page;
|
||||
this.loadFeedbacks();
|
||||
},
|
||||
|
||||
/**
|
||||
* Paginator 分页事件
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.currentPage = Math.floor(event.first / this.pageSize) + 1;
|
||||
this.loadFeedbacks();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取状态严重程度(用于 Tag 组件)
|
||||
*/
|
||||
getStatusSeverity(status) {
|
||||
const severityMap = {
|
||||
'pending': 'warning',
|
||||
'processing': 'info',
|
||||
'completed': 'success',
|
||||
'rejected': 'danger'
|
||||
};
|
||||
return severityMap[status] || 'secondary';
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取类型文本
|
||||
*/
|
||||
getTypeText(type) {
|
||||
const typeMap = {
|
||||
'bug': 'Bug反馈',
|
||||
'suggestion': '功能建议',
|
||||
'question': '使用问题',
|
||||
'other': '其他'
|
||||
};
|
||||
return typeMap[type] || type || '-';
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取状态文本
|
||||
*/
|
||||
getStatusText(status) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
'processing': '处理中',
|
||||
'completed': '已完成',
|
||||
'rejected': '已拒绝'
|
||||
};
|
||||
return statusMap[status] || status || '-';
|
||||
},
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
formatTime(time) {
|
||||
if (!time) return '-';
|
||||
const date = new Date(time);
|
||||
return date.toLocaleString('zh-CN');
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示成功消息
|
||||
*/
|
||||
showSuccessMessage(message) {
|
||||
this.successMessage = message;
|
||||
this.showSuccess = true;
|
||||
setTimeout(() => {
|
||||
this.showSuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-feedback {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.feedback-form-section {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
.required {
|
||||
color: #f44336;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.btn-primary {
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.btn-primary) {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
|
||||
&:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-history-section {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.feedback-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.feedback-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
|
||||
thead {
|
||||
background: #f5f5f5;
|
||||
|
||||
th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
vertical-align: middle;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
max-width: 400px;
|
||||
|
||||
.content-text {
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 15px;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-top: 5px;
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
733
app/views/InvitePage.vue
Normal file
733
app/views/InvitePage.vue
Normal file
@@ -0,0 +1,733 @@
|
||||
<template>
|
||||
<div class="page-invite">
|
||||
<h2 class="page-title">推广邀请</h2>
|
||||
|
||||
<!-- 邀请信息卡片 -->
|
||||
<Card class="invite-card">
|
||||
<template #title>我的邀请链接</template>
|
||||
<template #content>
|
||||
<div class="invite-code-section">
|
||||
<div class="link-display">
|
||||
<span class="link-label">邀请链接:</span>
|
||||
<span class="link-value">{{ inviteInfo.invite_link || '加载中...' }}</span>
|
||||
<Button
|
||||
label="复制链接"
|
||||
size="small"
|
||||
@click="handleCopyLink"
|
||||
v-if="inviteInfo.invite_link"
|
||||
/>
|
||||
</div>
|
||||
<div class="code-display" v-if="inviteInfo.invite_code">
|
||||
<span class="code-label">邀请码:</span>
|
||||
<span class="code-value">{{ inviteInfo.invite_code }}</span>
|
||||
<Button label="复制" size="small" @click="handleCopyCode" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="invite-tip">
|
||||
<p>💡 分享邀请链接给好友,好友通过链接注册后,您将获得 <strong>3天试用期</strong> 奖励</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
|
||||
<!-- 邀请记录列表 -->
|
||||
<div class="records-section">
|
||||
<div class="section-header">
|
||||
<h3>邀请记录
|
||||
<Badge :value="(statistics && statistics.totalInvites) || 0" severity="success" class="stat-value" />
|
||||
</h3>
|
||||
<Button
|
||||
label="刷新"
|
||||
@click="loadRecords"
|
||||
:loading="recordsLoading"
|
||||
:disabled="recordsLoading"
|
||||
/>
|
||||
</div>
|
||||
<ProgressSpinner v-if="recordsLoading" />
|
||||
<div v-else-if="recordsList && recordsList.length > 0" class="records-list">
|
||||
<Card
|
||||
v-for="record in recordsList"
|
||||
:key="record.id"
|
||||
class="record-item"
|
||||
>
|
||||
<template #content>
|
||||
<div class="record-info">
|
||||
<div class="record-phone">{{ record.invitee_phone || '未知' }}</div>
|
||||
<div class="record-time">{{ formatTime(record.register_time) }}</div>
|
||||
</div>
|
||||
<div class="record-status">
|
||||
<Tag
|
||||
:value="record.reward_status === 1 ? '已奖励' : '待奖励'"
|
||||
:severity="record.reward_status === 1 ? 'success' : 'warning'"
|
||||
/>
|
||||
<span v-if="record.reward_status === 1" class="reward-info">
|
||||
+{{ record.reward_value || 3 }}天
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
<div v-else class="empty-tip">暂无邀请记录</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Paginator
|
||||
v-if="recordsTotal > 0"
|
||||
:rows="pageSize"
|
||||
:totalRecords="recordsTotal"
|
||||
:first="(currentPage - 1) * pageSize"
|
||||
@page="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 邀请说明 -->
|
||||
<div class="info-section">
|
||||
<h3>邀请说明</h3>
|
||||
<ul class="info-list">
|
||||
<li>分享您的邀请链接给好友</li>
|
||||
<li>好友通过您的邀请链接注册后,您将自动获得 <strong>3天试用期</strong> 奖励</li>
|
||||
<li>每成功邀请一位用户注册,您将获得3天试用期</li>
|
||||
<li>试用期将自动累加到您的账户剩余天数中</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<Message
|
||||
v-if="showSuccess"
|
||||
severity="success"
|
||||
:closable="true"
|
||||
@close="showSuccess = false"
|
||||
class="success-message"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import inviteAPI from '../api/invite.js';
|
||||
import { mapState } from 'vuex';
|
||||
import { Card, Button, Badge, Tag, Paginator, Message, ProgressSpinner } from '../components/PrimeVue';
|
||||
import logMixin from '../mixins/logMixin.js';
|
||||
|
||||
export default {
|
||||
name: 'InvitePage',
|
||||
mixins: [logMixin],
|
||||
components: {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Tag,
|
||||
Paginator,
|
||||
Message,
|
||||
ProgressSpinner
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inviteInfo: {},
|
||||
statistics: {},
|
||||
showSuccess: false,
|
||||
successMessage: '',
|
||||
recordsList: [],
|
||||
recordsLoading: false,
|
||||
recordsTotal: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState('auth', ['snCode', 'isLoggedIn']),
|
||||
totalPages() {
|
||||
return Math.ceil(this.recordsTotal / this.pageSize);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 监听登录状态变化
|
||||
isLoggedIn(newVal) {
|
||||
if (newVal && this.snCode) {
|
||||
this.loadInviteInfo();
|
||||
this.loadStatistics();
|
||||
this.loadRecords();
|
||||
}
|
||||
},
|
||||
// 监听SN码变化
|
||||
snCode(newVal) {
|
||||
if (newVal && this.isLoggedIn) {
|
||||
this.loadInviteInfo();
|
||||
this.loadStatistics();
|
||||
this.loadRecords();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 确保在登录状态且SN码存在时才加载数据
|
||||
this.$nextTick(() => {
|
||||
if (this.isLoggedIn && this.snCode) {
|
||||
this.loadInviteInfo();
|
||||
this.loadStatistics();
|
||||
this.loadRecords();
|
||||
}
|
||||
});
|
||||
},
|
||||
activated() {
|
||||
// 当页面被激活时(切换到这个页面时)重新加载邀请信息
|
||||
// 确保在登录状态且SN码存在时才加载
|
||||
this.$nextTick(() => {
|
||||
if (this.isLoggedIn && this.snCode) {
|
||||
this.loadInviteInfo();
|
||||
this.loadStatistics();
|
||||
this.loadRecords();
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 加载邀请信息
|
||||
*/
|
||||
async loadInviteInfo() {
|
||||
try {
|
||||
if (!this.snCode) {
|
||||
console.warn('SN码不存在,无法加载邀请信息');
|
||||
return;
|
||||
}
|
||||
const result = await inviteAPI.getInviteInfo(this.snCode);
|
||||
// 支持新的响应格式:{ code: 0 } 或旧的格式:{ success: true }
|
||||
if (result && (result.code === 0 || result.success)) {
|
||||
this.inviteInfo = result.data || {};
|
||||
// 如果没有邀请链接,自动生成
|
||||
if (!this.inviteInfo.invite_link) {
|
||||
await this.handleGenerateCode();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载邀请信息失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '加载邀请信息失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载统计数据
|
||||
*/
|
||||
async loadStatistics() {
|
||||
try {
|
||||
if (!this.snCode) {
|
||||
return;
|
||||
}
|
||||
const result = await inviteAPI.getStatistics(this.snCode);
|
||||
// 支持新的响应格式:{ code: 0 } 或旧的格式:{ success: true }
|
||||
if (result && (result.code === 0 || result.success)) {
|
||||
this.statistics = result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成新邀请码
|
||||
*/
|
||||
async handleGenerateCode() {
|
||||
try {
|
||||
if (!this.snCode) {
|
||||
console.warn('SN码不存在,无法生成邀请码');
|
||||
return;
|
||||
}
|
||||
const result = await inviteAPI.generateInviteCode(this.snCode);
|
||||
// 支持新的响应格式:{ code: 0 } 或旧的格式:{ success: true }
|
||||
if (result && (result.code === 0 || result.success)) {
|
||||
this.inviteInfo = result.data || {};
|
||||
if (!this.showSuccess) {
|
||||
this.showSuccessMessage('邀请链接已生成!');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成邀请码失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '生成邀请码失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 复制邀请码
|
||||
*/
|
||||
async handleCopyCode() {
|
||||
if (!this.inviteInfo.invite_code) return;
|
||||
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.clipboard) {
|
||||
await window.electronAPI.clipboard.writeText(this.inviteInfo.invite_code);
|
||||
this.showSuccessMessage('邀请码已复制到剪贴板');
|
||||
} else {
|
||||
// 使用浏览器API
|
||||
await navigator.clipboard.writeText(this.inviteInfo.invite_code);
|
||||
this.showSuccessMessage('邀请码已复制到剪贴板');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '复制失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 复制邀请链接
|
||||
*/
|
||||
async handleCopyLink() {
|
||||
if (!this.inviteInfo.invite_link) return;
|
||||
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.clipboard) {
|
||||
await window.electronAPI.clipboard.writeText(this.inviteInfo.invite_link);
|
||||
this.showSuccessMessage('邀请链接已复制到剪贴板');
|
||||
} else {
|
||||
// 使用浏览器API
|
||||
await navigator.clipboard.writeText(this.inviteInfo.invite_link);
|
||||
this.showSuccessMessage('邀请链接已复制到剪贴板');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '复制失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示成功消息
|
||||
*/
|
||||
showSuccessMessage(message) {
|
||||
this.successMessage = message;
|
||||
this.showSuccess = true;
|
||||
setTimeout(() => {
|
||||
this.showSuccess = false;
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载邀请记录列表
|
||||
*/
|
||||
async loadRecords() {
|
||||
try {
|
||||
if (!this.snCode) {
|
||||
return;
|
||||
}
|
||||
this.recordsLoading = true;
|
||||
const result = await inviteAPI.getRecords(this.snCode, {
|
||||
page: this.currentPage,
|
||||
pageSize: this.pageSize
|
||||
});
|
||||
if (result && (result.code === 0 || result.success)) {
|
||||
this.recordsList = result.data?.list || [];
|
||||
this.recordsTotal = result.data?.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载邀请记录列表失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '加载邀请记录列表失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
} finally {
|
||||
this.recordsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换页码
|
||||
*/
|
||||
changePage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.loadRecords();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Paginator 分页事件
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.currentPage = Math.floor(event.first / this.pageSize) + 1;
|
||||
this.loadRecords();
|
||||
},
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
formatTime(time) {
|
||||
if (!time) return '-';
|
||||
const date = new Date(time);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-invite {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.invite-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.invite-code-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.invite-tip {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #e8f5e9;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #4CAF50;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #2e7d32;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
strong {
|
||||
color: #1b5e20;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-display,
|
||||
.link-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-label,
|
||||
.link-label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.code-value,
|
||||
.link-value {
|
||||
flex: 1;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #4CAF50;
|
||||
font-size: 16px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
padding: 6px 12px;
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.records-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
padding: 6px 12px;
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-tip,
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #4CAF50;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.record-info {
|
||||
flex: 1;
|
||||
|
||||
.record-phone {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.record-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&.status-success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
}
|
||||
|
||||
.reward-info {
|
||||
font-size: 14px;
|
||||
color: #4CAF50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 6px 16px;
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.info-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
margin-bottom: 10px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.btn-primary {
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.success-message {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
163
app/views/LogPage.vue
Normal file
163
app/views/LogPage.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="page-log">
|
||||
<h2 class="page-title">运行日志</h2>
|
||||
|
||||
<!-- 日志控制 -->
|
||||
<div class="log-controls-section">
|
||||
<div class="log-controls">
|
||||
<Button class="btn" @click="handleClearLogs">清空日志</Button>
|
||||
<Button class="btn" @click="handleExportLogs">导出日志</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志内容 -->
|
||||
<div class="log-content-section">
|
||||
<div class="log-container" id="log-container">
|
||||
<div v-for="(log, index) in logEntries" :key="index" class="log-entry">
|
||||
<span class="log-time">[{{ log.time }}]</span>
|
||||
<span :class="['log-level', log.level.toLowerCase()]">[{{ log.level }}]</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
<div v-if="logEntries.length === 0" class="log-empty">
|
||||
<p>暂无日志记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Button } from '../components/PrimeVue';
|
||||
import { mapState, mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'LogPage',
|
||||
components: {
|
||||
Button
|
||||
},
|
||||
computed: {
|
||||
...mapState('log', ['logs']),
|
||||
logEntries() {
|
||||
return this.logs;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
updated() {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
methods: {
|
||||
...mapActions('log', ['clearLogs', 'exportLogs']),
|
||||
handleClearLogs() {
|
||||
this.clearLogs();
|
||||
},
|
||||
handleExportLogs() {
|
||||
this.exportLogs();
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
const logContainer = document.getElementById('log-container');
|
||||
if (logContainer) {
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
logEntries() {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-log {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-controls-section {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-content-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #808080;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
|
||||
&.info {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
&.debug {
|
||||
color: #90caf9;
|
||||
}
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #808080;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
361
app/views/LoginPage.vue
Normal file
361
app/views/LoginPage.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<div class="page-login">
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1>用户登录</h1>
|
||||
<p class="login-subtitle">请输入您的邮箱和密码进行登录</p>
|
||||
</div>
|
||||
|
||||
<div class="login-form">
|
||||
<Message
|
||||
v-if="errorMessage"
|
||||
severity="error"
|
||||
:closable="false"
|
||||
class="error-message"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">邮箱</label>
|
||||
<InputText
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
autocomplete="email"
|
||||
class="form-input"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">密码</label>
|
||||
<InputText
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
class="form-input"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-options">
|
||||
<div class="remember-me">
|
||||
<Checkbox v-model="rememberMe" inputId="remember" binary />
|
||||
<label for="remember" class="remember-label">记住登录信息</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
label="登录"
|
||||
@click="handleLogin"
|
||||
:disabled="isLoading || !email || !password"
|
||||
:loading="isLoading"
|
||||
class="btn-block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { InputText, Password, Button, Checkbox, Message } from '../components/PrimeVue';
|
||||
|
||||
export default {
|
||||
name: 'LoginPage',
|
||||
components: {
|
||||
InputText,
|
||||
Password,
|
||||
Button,
|
||||
Checkbox,
|
||||
Message
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: true,
|
||||
isLoading: false,
|
||||
errorMessage: ''
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// 从 store 加载保存的邮箱
|
||||
if (this.$store) {
|
||||
const savedEmail = this.$store.state.config.email || this.$store.state.auth.email;
|
||||
if (savedEmail) {
|
||||
this.email = savedEmail;
|
||||
}
|
||||
const rememberMe = this.$store.state.config.rememberMe;
|
||||
if (rememberMe !== undefined) {
|
||||
this.rememberMe = rememberMe;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 生成设备ID(基于浏览器特征)
|
||||
* @returns {string} 设备ID
|
||||
*/
|
||||
generateDeviceId() {
|
||||
// 尝试从 store 获取已保存的设备ID
|
||||
if (this.$store) {
|
||||
let deviceId = this.$store.state.config.deviceId || this.$store.state.auth.deviceId;
|
||||
if (deviceId) {
|
||||
return deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
// 基于浏览器特征生成设备ID
|
||||
try {
|
||||
const navigatorInfo = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
navigator.platform,
|
||||
screen.width,
|
||||
screen.height,
|
||||
screen.colorDepth,
|
||||
new Date().getTimezoneOffset()
|
||||
].join('|');
|
||||
|
||||
// 使用简单的哈希算法生成ID
|
||||
let hash = 0;
|
||||
for (let i = 0; i < navigatorInfo.length; i++) {
|
||||
const char = navigatorInfo.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
|
||||
const deviceId = 'web_' + Math.abs(hash).toString(16).substring(0, 32);
|
||||
|
||||
// 保存生成的 device_id 到 store
|
||||
if (this.$store) {
|
||||
this.$store.dispatch('config/setDeviceId', deviceId);
|
||||
this.$store.commit('auth/SET_DEVICE_ID', deviceId);
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
} catch (error) {
|
||||
console.error('生成设备ID失败:', error);
|
||||
// 如果生成失败,使用随机字符串作为后备方案
|
||||
const fallbackId = 'web_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 15);
|
||||
if (this.$store) {
|
||||
this.$store.dispatch('config/setDeviceId', fallbackId);
|
||||
this.$store.commit('auth/SET_DEVICE_ID', fallbackId);
|
||||
}
|
||||
return fallbackId;
|
||||
}
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
if (!this.email) {
|
||||
this.errorMessage = '请输入邮箱地址';
|
||||
return;
|
||||
}
|
||||
if (!this.password) {
|
||||
this.errorMessage = '请输入密码';
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(this.email)) {
|
||||
this.errorMessage = '邮箱格式不正确,请输入有效的邮箱地址';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
// 生成或获取设备ID
|
||||
const deviceId = this.generateDeviceId();
|
||||
|
||||
// 调用 Vuex store 的 login action(会自动调用 HTTP 接口)
|
||||
const result = await this.$store.dispatch('auth/login', {
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
deviceId: deviceId
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 保存记住登录选项到 store
|
||||
if (this.$store) {
|
||||
this.$store.dispatch('config/setRememberMe', this.rememberMe);
|
||||
if (this.rememberMe) {
|
||||
this.$store.dispatch('config/setEmail', this.email);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待 store 状态更新完成后再跳转
|
||||
await this.$nextTick();
|
||||
|
||||
// 跳转到控制台(App.vue 的 watch 会自动连接 MQTT)
|
||||
this.$router.push('/console');
|
||||
} else {
|
||||
this.errorMessage = result.error || '登录失败,请检查邮箱和密码';
|
||||
this.isLoading = false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorMessage = `登录失败: ${error.message}`;
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
setError(message) {
|
||||
this.errorMessage = message;
|
||||
this.isLoading = false;
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.password = '';
|
||||
this.errorMessage = '';
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-login {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// PrimeVue InputText 样式覆盖
|
||||
:deep(.p-inputtext) {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
|
||||
&:enabled:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
&:enabled:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-options {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
|
||||
.remember-label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
:deep(.p-button) {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s, box-shadow 0.3s;
|
||||
|
||||
&:enabled:hover {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #653a8b 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
&:enabled:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-login {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
501
app/views/PurchasePage.vue
Normal file
501
app/views/PurchasePage.vue
Normal file
@@ -0,0 +1,501 @@
|
||||
<template>
|
||||
<div class="page-purchase">
|
||||
<h2 class="page-title">如何购买</h2>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<div class="contact-section">
|
||||
<Card class="contact-card">
|
||||
<template #title>联系购买</template>
|
||||
<template #content>
|
||||
<p class="contact-desc">扫描下方二维码或添加微信号联系我们</p>
|
||||
<div class="contact-content">
|
||||
<div class="qr-code-wrapper">
|
||||
<div class="qr-code-placeholder">
|
||||
<img v-if="wechatQRCode" :src="wechatQRCode" alt="微信二维码" class="qr-code-image" />
|
||||
<div v-else class="qr-code-placeholder-text">
|
||||
<span>微信二维码</span>
|
||||
<small>请上传二维码图片</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">微信号:</span>
|
||||
<span class="info-value">{{ wechatNumber }}</span>
|
||||
<Button label="复制" size="small" @click="handleCopyWechat" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 价格套餐 -->
|
||||
<div class="pricing-section">
|
||||
<h3 class="section-title">价格套餐</h3>
|
||||
<div class="pricing-grid">
|
||||
<Card
|
||||
v-for="plan in pricingPlans"
|
||||
:key="plan.id"
|
||||
:class="['pricing-card', { 'featured': plan.featured }]"
|
||||
>
|
||||
<template #header>
|
||||
<Badge v-if="plan.featured" value="推荐" severity="success" />
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="plan-header">
|
||||
<h4 class="plan-name">{{ plan.name }}</h4>
|
||||
<div class="plan-duration">{{ plan.duration }}</div>
|
||||
</div>
|
||||
<div class="plan-price">
|
||||
<div v-if="plan.originalPrice && plan.originalPrice > plan.price" class="original-price">
|
||||
<span class="original-price-text">原价 ¥{{ plan.originalPrice }}</span>
|
||||
</div>
|
||||
<div class="current-price">
|
||||
<span class="price-symbol">¥</span>
|
||||
<span class="price-amount">{{ plan.price }}</span>
|
||||
<span class="price-unit" v-if="plan.unit">{{ plan.unit }}</span>
|
||||
</div>
|
||||
<Badge v-if="plan.discount" :value="plan.discount" severity="danger" class="discount-badge" />
|
||||
</div>
|
||||
<div class="plan-features">
|
||||
<div class="feature-item" v-for="feature in plan.features" :key="feature">
|
||||
<span class="feature-icon">✓</span>
|
||||
<span>{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-action">
|
||||
<Button label="立即购买" @click="handleContact(plan)" class="btn-full-width" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 购买说明 -->
|
||||
<div class="notice-section">
|
||||
<Card class="notice-card">
|
||||
<template #title>购买说明</template>
|
||||
<template #content>
|
||||
<ul class="notice-list">
|
||||
<li>购买后请联系客服激活账号</li>
|
||||
<li>终生套餐享受永久使用权限</li>
|
||||
<li>如有疑问,请添加微信号咨询</li>
|
||||
</ul>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<Message
|
||||
v-if="showSuccess"
|
||||
severity="success"
|
||||
:closable="true"
|
||||
@close="showSuccess = false"
|
||||
class="success-message"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import configAPI from '../api/config.js';
|
||||
import { Card, Button, Badge, Message } from '../components/PrimeVue';
|
||||
import logMixin from '../mixins/logMixin.js';
|
||||
|
||||
export default {
|
||||
name: 'PurchasePage',
|
||||
mixins: [logMixin],
|
||||
components: {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Message
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
wechatNumber: '', // 从配置中获取
|
||||
wechatQRCode: '', // 从配置中获取
|
||||
showSuccess: false,
|
||||
successMessage: '',
|
||||
loading: false,
|
||||
pricingPlans: [] // 从接口获取
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadWechatConfig();
|
||||
this.loadPricingPlans();
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 加载微信配置
|
||||
*/
|
||||
async loadWechatConfig() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const config = await configAPI.getWechatConfig();
|
||||
if (config.wechatNumber) {
|
||||
this.wechatNumber = config.wechatNumber;
|
||||
}
|
||||
if (config.wechatQRCode) {
|
||||
this.wechatQRCode = config.wechatQRCode;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载微信配置失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '加载微信配置失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载价格套餐列表
|
||||
*/
|
||||
async loadPricingPlans() {
|
||||
try {
|
||||
const plans = await configAPI.getPricingPlans();
|
||||
if (plans && plans.length > 0) {
|
||||
this.pricingPlans = plans;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载价格套餐失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '加载价格套餐失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 复制微信号
|
||||
*/
|
||||
async handleCopyWechat() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.clipboard) {
|
||||
await window.electronAPI.clipboard.writeText(this.wechatNumber);
|
||||
this.showSuccessMessage('微信号已复制到剪贴板');
|
||||
} else {
|
||||
throw new Error('electronAPI 不可用,无法复制');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
if (this.addLog) {
|
||||
this.addLog('error', '复制失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 联系购买
|
||||
*/
|
||||
handleContact(plan) {
|
||||
const message = `我想购买【${plan.name}】(${plan.duration}),价格:¥${plan.price}${plan.unit}`;
|
||||
this.showSuccessMessage(`请添加微信号 ${this.wechatNumber} 并发送:"${message}"`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示成功消息
|
||||
*/
|
||||
showSuccessMessage(message) {
|
||||
this.successMessage = message;
|
||||
this.showSuccess = true;
|
||||
setTimeout(() => {
|
||||
this.showSuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-purchase {
|
||||
padding: 15px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.contact-desc {
|
||||
text-align: center;
|
||||
margin: 0 0 15px 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.contact-content {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr-code-placeholder {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
|
||||
.qr-code-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.qr-code-placeholder-text {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
min-width: 70px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.pricing-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 15px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
&.featured {
|
||||
border: 2px solid #4CAF50;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
margin-bottom: 15px;
|
||||
|
||||
.plan-name {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.plan-duration {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
position: relative;
|
||||
|
||||
.original-price {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.original-price-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.current-price {
|
||||
.price-symbol {
|
||||
font-size: 18px;
|
||||
color: #4CAF50;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.price-amount {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #4CAF50;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.discount-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-features {
|
||||
margin-bottom: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
|
||||
.feature-icon {
|
||||
color: #4CAF50;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-action {
|
||||
:deep(.p-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-section {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
// Card 组件已包含样式
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
margin-bottom: 8px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
|
||||
&::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #4CAF50;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.success-message {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contact-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pricing-card.featured {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user