This commit is contained in:
张成
2025-12-22 16:26:59 +08:00
parent aa2d03ee30
commit e17d5610f5
54 changed files with 11735 additions and 3 deletions

1328
app/views/ConsolePage.vue Normal file

File diff suppressed because it is too large Load Diff

671
app/views/DeliveryPage.vue Normal file
View 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
View 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
View 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
View 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
View 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
View 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>