新增cursor续杯界面

This commit is contained in:
李志强 2026-05-11 14:24:54 +08:00
parent 93ece610ff
commit c6b97f79f2
7 changed files with 1484 additions and 0 deletions

View File

@ -0,0 +1,65 @@
import request from '@/utils/request';
const baseUrl = '/platform/cursor/equipment';
export function getCursorEquipmentList(params) {
return request({
url: `${baseUrl}/list`,
method: 'get',
params,
});
}
export function getCursorEquipmentDetail(id) {
return request({
url: `${baseUrl}/detail/${id}`,
method: 'get',
});
}
export function addCursorEquipment(data) {
return request({
url: `${baseUrl}/add`,
method: 'post',
data,
});
}
export function updateCursorEquipment(data) {
return request({
url: `${baseUrl}/update`,
method: 'post',
data,
});
}
export function deleteCursorEquipment(id) {
return request({
url: `${baseUrl}/delete/${id}`,
method: 'post',
});
}
export function activateCursorEquipment(data) {
return request({
url: `${baseUrl}/activate`,
method: 'post',
data,
});
}
export function getCursorEquipmentActivationRecords(params) {
return request({
url: `${baseUrl}/activationRecords`,
method: 'get',
params,
});
}
export function getCursorEquipmentExtractRecords(params) {
return request({
url: `${baseUrl}/extractRecords`,
method: 'get',
params,
});
}

View File

@ -0,0 +1,137 @@
<script lang="ts" setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
row: {
type: Object,
default: null,
},
loading: {
type: Boolean,
default: false,
},
records: {
type: Array,
default: () => [],
},
total: {
type: Number,
default: 0,
},
page: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 20,
},
});
const emit = defineEmits(['update:modelValue', 'update:page', 'update:pageSize', 'refresh']);
function statusText(status: unknown) {
const value = String(status || '');
if (value === 'success' || value === '1') return '成功';
if (value === 'failed' || value === '0') return '失败';
return value || '-';
}
function statusType(status: unknown) {
const value = String(status || '');
if (value === 'success' || value === '1') return 'success';
if (value === 'failed' || value === '0') return 'danger';
return 'info';
}
</script>
<template>
<el-drawer
class="equipment-record-drawer"
:model-value="modelValue"
title="激活记录"
size="760px"
direction="rtl"
@update:model-value="(v) => emit('update:modelValue', v)"
@opened="emit('refresh')"
>
<div class="record-header">
<div>
<div class="record-title">{{ row?.name || '-' }}</div>
<div class="record-subtitle">设备编号{{ row?.deviceNo || '-' }}</div>
</div>
<el-button :loading="loading" @click="emit('refresh')">刷新</el-button>
</div>
<el-table v-loading="loading" :data="records" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row: item }">
<el-tag :type="statusType(item.status)" size="small">
{{ statusText(item.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="account" label="激活账号" min-width="150" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" min-width="130" show-overflow-tooltip />
<el-table-column prop="clientVersion" label="客户端版本" width="120" />
<el-table-column prop="createdAt" label="激活时间" width="180" />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
</el-table>
<div class="record-pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
background
layout="total, prev, pager, next, jumper"
:total="total"
@update:current-page="(v) => emit('update:page', v)"
@update:page-size="(v) => emit('update:pageSize', v)"
/>
</div>
</el-drawer>
</template>
<style scoped lang="less">
.record-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.record-title {
font-weight: 600;
color: #303133;
line-height: 1.6;
}
.record-subtitle {
color: #909399;
font-size: 13px;
}
.record-pager {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
@media (max-width: 768px) {
:deep(.equipment-record-drawer) {
width: 100vw !important;
}
.record-header {
align-items: flex-start;
}
.record-pager {
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
row: {
type: Object,
default: null,
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'confirm']);
function close() {
emit('update:modelValue', false);
}
</script>
<template>
<el-dialog
class="equipment-delete-dialog"
:model-value="modelValue"
title="删除设备"
width="460px"
@update:model-value="(v) => emit('update:modelValue', v)"
>
<el-alert type="warning" :closable="false" show-icon>
删除后设备及相关记录可能无法恢复请谨慎操作
</el-alert>
<div v-if="props.row" class="delete-content">
<div class="delete-row">
<span class="label">设备名称</span>
<span>{{ props.row.name || '-' }}</span>
</div>
<div class="delete-row">
<span class="label">设备编号</span>
<span>{{ props.row.deviceNo || '-' }}</span>
</div>
<div class="delete-row">
<span class="label">机器码</span>
<span class="code">{{ props.row.machineCode || '-' }}</span>
</div>
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="danger" :loading="loading" @click="emit('confirm')">确认删除</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="less">
.delete-content {
margin-top: 16px;
padding: 12px 14px;
background: #f8fafc;
border-radius: 6px;
}
.delete-row {
display: flex;
gap: 8px;
line-height: 1.8;
font-size: 14px;
.label {
flex-shrink: 0;
color: #909399;
}
.code {
word-break: break-all;
}
}
:deep(.equipment-delete-dialog) {
max-width: calc(100vw - 24px);
}
</style>

View File

@ -0,0 +1,163 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
row: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update:modelValue']);
const statusMap: Record<string, { label: string; type: string }> = {
active: { label: '正常', type: 'success' },
inactive: { label: '未激活', type: 'info' },
disabled: { label: '禁用', type: 'danger' },
expired: { label: '已过期', type: 'warning' },
};
const statusInfo = computed(() => {
const status = String(props.row?.status || 'inactive');
return statusMap[status] || { label: status || '-', type: 'info' };
});
function display(value: unknown) {
if (value === null || value === undefined || value === '') return '-';
return String(value);
}
function copyText(text: unknown, label = '内容') {
const value = String(text || '').trim();
if (!value) {
ElMessage.warning(`暂无${label}可复制`);
return;
}
navigator.clipboard.writeText(value).then(() => {
ElMessage.success(`${label}已复制`);
});
}
</script>
<template>
<el-dialog
class="equipment-detail-dialog"
:model-value="modelValue"
title="设备详情"
width="760px"
@update:model-value="(v) => emit('update:modelValue', v)"
>
<el-descriptions v-if="row" :column="2" border>
<el-descriptions-item label="设备ID">
{{ display(row.id) }}
</el-descriptions-item>
<el-descriptions-item label="设备状态">
<el-tag :type="statusInfo.type">{{ statusInfo.label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="设备名称">
{{ display(row.name) }}
</el-descriptions-item>
<el-descriptions-item label="设备编号">
<span class="value-with-action">
<span>{{ display(row.deviceNo) }}</span>
<el-button v-if="row.deviceNo" link type="primary" @click="copyText(row.deviceNo, '设备编号')">
复制
</el-button>
</span>
</el-descriptions-item>
<el-descriptions-item label="机器码">
<span class="code-text">{{ display(row.machineCode) }}</span>
</el-descriptions-item>
<el-descriptions-item label="授权码">
<span class="code-text">{{ display(row.licenseCode) }}</span>
</el-descriptions-item>
<el-descriptions-item label="系统平台">
{{ display(row.os) }}
</el-descriptions-item>
<el-descriptions-item label="版本">
{{ display(row.version) }}
</el-descriptions-item>
<el-descriptions-item label="绑定账号">
{{ display(row.account) }}
</el-descriptions-item>
<el-descriptions-item label="归属用户">
{{ display(row.owner) }}
</el-descriptions-item>
<el-descriptions-item label="激活次数">
{{ Number(row.activationCount || 0) }}
</el-descriptions-item>
<el-descriptions-item label="提取次数">
{{ Number(row.extractCount || 0) }}
</el-descriptions-item>
<el-descriptions-item label="最后激活时间">
{{ display(row.lastActivatedAt) }}
</el-descriptions-item>
<el-descriptions-item label="最后提取时间">
{{ display(row.lastExtractedAt) }}
</el-descriptions-item>
<el-descriptions-item label="过期时间">
{{ display(row.expiredAt) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ display(row.createdAt) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
<span class="remark-text">{{ display(row.remark) }}</span>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="less">
.value-with-action {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
}
.code-text,
.remark-text {
word-break: break-all;
white-space: pre-wrap;
line-height: 1.6;
}
:deep(.equipment-detail-dialog) {
max-width: calc(100vw - 24px);
}
@media (max-width: 768px) {
:deep(.equipment-detail-dialog) {
width: calc(100vw - 24px) !important;
margin: 0 auto;
}
:deep(.equipment-detail-dialog .el-dialog__body) {
padding: 12px;
max-height: 70vh;
overflow-y: auto;
}
:deep(.el-descriptions__body .el-descriptions__table) {
display: block;
}
:deep(.el-descriptions__body tbody),
:deep(.el-descriptions__body tr),
:deep(.el-descriptions__body th),
:deep(.el-descriptions__body td) {
display: block;
width: 100% !important;
}
}
</style>

View File

@ -0,0 +1,200 @@
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
row: {
type: Object,
default: null,
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'submit']);
const formRef = ref();
const form = reactive({
id: '',
name: '',
deviceNo: '',
machineCode: '',
licenseCode: '',
os: '',
version: '',
account: '',
owner: '',
status: 'inactive',
expiredAt: '',
remark: '',
});
const rules = {
name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
deviceNo: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
machineCode: [{ required: true, message: '请输入机器码', trigger: 'blur' }],
status: [{ required: true, message: '请选择设备状态', trigger: 'change' }],
};
function resetForm() {
form.id = '';
form.name = '';
form.deviceNo = '';
form.machineCode = '';
form.licenseCode = '';
form.os = '';
form.version = '';
form.account = '';
form.owner = '';
form.status = 'inactive';
form.expiredAt = '';
form.remark = '';
formRef.value?.clearValidate?.();
}
function fillForm(row: any) {
form.id = row?.id || '';
form.name = row?.name || '';
form.deviceNo = row?.deviceNo || '';
form.machineCode = row?.machineCode || '';
form.licenseCode = row?.licenseCode || '';
form.os = row?.os || '';
form.version = row?.version || '';
form.account = row?.account || '';
form.owner = row?.owner || '';
form.status = row?.status || 'inactive';
form.expiredAt = row?.expiredAt || '';
form.remark = row?.remark || '';
}
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
resetForm();
if (props.row) fillForm(props.row);
},
);
async function handleSubmit() {
await formRef.value?.validate?.();
emit('submit', { ...form });
}
</script>
<template>
<el-dialog
class="equipment-edit-dialog"
:model-value="modelValue"
:title="row?.id ? '编辑设备' : '新增设备'"
width="720px"
destroy-on-close
@update:model-value="(v) => emit('update:modelValue', v)"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
<el-row :gutter="14">
<el-col :xs="24" :sm="12">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" placeholder="请输入设备名称" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="设备编号" prop="deviceNo">
<el-input v-model="form.deviceNo" placeholder="请输入设备编号" clearable />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="机器码" prop="machineCode">
<el-input v-model="form.machineCode" placeholder="请输入设备机器码 / 指纹" clearable />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="授权码">
<el-input v-model="form.licenseCode" placeholder="请输入授权码" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="系统平台">
<el-select v-model="form.os" placeholder="请选择系统平台" clearable style="width: 100%">
<el-option label="Windows" value="Windows" />
<el-option label="macOS" value="macOS" />
<el-option label="Linux" value="Linux" />
<el-option label="其他" value="Other" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="版本">
<el-input v-model="form.version" placeholder="请输入客户端版本" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="绑定账号">
<el-input v-model="form.account" placeholder="请输入绑定账号" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="归属用户">
<el-input v-model="form.owner" placeholder="请输入归属用户" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="设备状态" prop="status">
<el-select v-model="form.status" placeholder="请选择状态" style="width: 100%">
<el-option label="未激活" value="inactive" />
<el-option label="正常" value="active" />
<el-option label="禁用" value="disabled" />
<el-option label="已过期" value="expired" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="过期时间">
<el-date-picker
v-model="form.expiredAt"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择过期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="4" placeholder="请输入备注" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="less">
:deep(.equipment-edit-dialog) {
max-width: calc(100vw - 24px);
}
@media (max-width: 768px) {
:deep(.equipment-edit-dialog) {
width: calc(100vw - 24px) !important;
margin: 0 auto;
}
:deep(.equipment-edit-dialog .el-dialog__body) {
padding: 12px;
max-height: 70vh;
overflow-y: auto;
}
}
</style>

View File

@ -0,0 +1,159 @@
<script lang="ts" setup>
import { ElMessage } from 'element-plus';
defineProps({
modelValue: {
type: Boolean,
default: false,
},
row: {
type: Object,
default: null,
},
loading: {
type: Boolean,
default: false,
},
records: {
type: Array,
default: () => [],
},
total: {
type: Number,
default: 0,
},
page: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 20,
},
});
const emit = defineEmits(['update:modelValue', 'update:page', 'update:pageSize', 'refresh']);
function copyContent(content: unknown) {
const text = String(content || '').trim();
if (!text) {
ElMessage.warning('暂无提取内容可复制');
return;
}
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('提取内容已复制');
});
}
function statusText(status: unknown) {
const value = String(status || '');
if (value === 'success' || value === '1') return '成功';
if (value === 'failed' || value === '0') return '失败';
return value || '-';
}
function statusType(status: unknown) {
const value = String(status || '');
if (value === 'success' || value === '1') return 'success';
if (value === 'failed' || value === '0') return 'danger';
return 'info';
}
</script>
<template>
<el-drawer
class="equipment-record-drawer"
:model-value="modelValue"
title="提取记录"
size="860px"
direction="rtl"
@update:model-value="(v) => emit('update:modelValue', v)"
@opened="emit('refresh')"
>
<div class="record-header">
<div>
<div class="record-title">{{ row?.name || '-' }}</div>
<div class="record-subtitle">设备编号{{ row?.deviceNo || '-' }}</div>
</div>
<el-button :loading="loading" @click="emit('refresh')">刷新</el-button>
</div>
<el-table v-loading="loading" :data="records" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row: item }">
<el-tag :type="statusType(item.status)" size="small">
{{ statusText(item.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="platform" label="提取平台" width="110" />
<el-table-column prop="type" label="提取类型" width="110" />
<el-table-column prop="account" label="提取账号" min-width="150" show-overflow-tooltip />
<el-table-column label="提取内容" min-width="220" show-overflow-tooltip>
<template #default="{ row: item }">
<span>{{ item.content || '-' }}</span>
<el-button v-if="item.content" link type="primary" @click="copyContent(item.content)">
复制
</el-button>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="130" show-overflow-tooltip />
<el-table-column prop="createdAt" label="提取时间" width="180" />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
</el-table>
<div class="record-pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
background
layout="total, prev, pager, next, jumper"
:total="total"
@update:current-page="(v) => emit('update:page', v)"
@update:page-size="(v) => emit('update:pageSize', v)"
/>
</div>
</el-drawer>
</template>
<style scoped lang="less">
.record-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.record-title {
font-weight: 600;
color: #303133;
line-height: 1.6;
}
.record-subtitle {
color: #909399;
font-size: 13px;
}
.record-pager {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
@media (max-width: 768px) {
:deep(.equipment-record-drawer) {
width: 100vw !important;
}
.record-header {
align-items: flex-start;
}
.record-pager {
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,675 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import DetailDialog from './components/detail.vue';
import EditDialog from './components/edit.vue';
import DeleteDialog from './components/delete.vue';
import ActivationRecords from './components/activationRecords.vue';
import ExtractRecords from './components/extractRecords.vue';
import {
activateCursorEquipment,
addCursorEquipment,
deleteCursorEquipment,
getCursorEquipmentActivationRecords,
getCursorEquipmentDetail,
getCursorEquipmentExtractRecords,
getCursorEquipmentList,
updateCursorEquipment,
} from '@/api/cursorEquipment';
type EquipmentRow = Record<string, any>;
const loading = ref(false);
const actionLoading = ref(false);
const detailVisible = ref(false);
const editVisible = ref(false);
const deleteVisible = ref(false);
const activationVisible = ref(false);
const extractVisible = ref(false);
const currentRow = ref<EquipmentRow | null>(null);
const selectedRows = ref<EquipmentRow[]>([]);
const tableData = ref<EquipmentRow[]>([]);
const total = ref(0);
const isMobile = ref(false);
const query = reactive({
keyword: '',
status: '',
os: '',
});
const pagination = reactive({
page: 1,
pageSize: 20,
});
const activationState = reactive({
loading: false,
records: [] as EquipmentRow[],
total: 0,
page: 1,
pageSize: 20,
});
const extractState = reactive({
loading: false,
records: [] as EquipmentRow[],
total: 0,
page: 1,
pageSize: 20,
});
const statusOptions = [
{ label: '未激活', value: 'inactive' },
{ label: '正常', value: 'active' },
{ label: '禁用', value: 'disabled' },
{ label: '已过期', value: 'expired' },
];
const osOptions = [
{ label: 'Windows', value: 'Windows' },
{ label: 'macOS', value: 'macOS' },
{ label: 'Linux', value: 'Linux' },
{ label: '其他', value: 'Other' },
];
const statusMap: Record<string, { label: string; type: string }> = {
active: { label: '正常', type: 'success' },
inactive: { label: '未激活', type: 'info' },
disabled: { label: '禁用', type: 'danger' },
expired: { label: '已过期', type: 'warning' },
};
const summary = computed(() => {
const active = tableData.value.filter((item) => item.status === 'active').length;
const inactive = tableData.value.filter((item) => item.status === 'inactive').length;
const disabled = tableData.value.filter((item) => item.status === 'disabled').length;
const expired = tableData.value.filter((item) => item.status === 'expired').length;
return [
{ label: '当前页设备', value: tableData.value.length, type: 'primary' },
{ label: '正常设备', value: active, type: 'success' },
{ label: '未激活', value: inactive, type: 'info' },
{ label: '禁用/过期', value: disabled + expired, type: 'danger' },
];
});
watch(
() => [query.keyword, query.status, query.os],
() => {
pagination.page = 1;
fetchList();
},
);
watch(
() => [pagination.page, pagination.pageSize],
() => {
fetchList();
},
);
watch(
() => [activationState.page, activationState.pageSize],
() => {
if (activationVisible.value) fetchActivationRecords();
},
);
watch(
() => [extractState.page, extractState.pageSize],
() => {
if (extractVisible.value) fetchExtractRecords();
},
);
function pick(raw: any, ...keys: string[]) {
for (const key of keys) {
if (raw?.[key] !== undefined && raw?.[key] !== null) return raw[key];
}
return '';
}
function formatTime(value: any) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
const p = (v: number) => String(v).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
function normalizeRow(raw: any): EquipmentRow {
const status = String(pick(raw, 'status', 'Status') || 'inactive');
return {
id: pick(raw, 'id', 'ID', 'Id'),
name: pick(raw, 'name', 'device_name', 'deviceName', 'Name', 'DeviceName'),
deviceNo: pick(raw, 'device_no', 'deviceNo', 'DeviceNo', 'serial_no', 'serialNo'),
machineCode: pick(raw, 'machine_code', 'machineCode', 'MachineCode', 'fingerprint'),
licenseCode: pick(raw, 'license_code', 'licenseCode', 'LicenseCode'),
os: pick(raw, 'os', 'OS', 'platform', 'Platform'),
version: pick(raw, 'version', 'Version', 'client_version', 'clientVersion'),
account: pick(raw, 'account', 'Account', 'email', 'Email'),
owner: pick(raw, 'owner', 'Owner', 'user_name', 'userName', 'tenant_name', 'tenantName'),
status,
activationCount: Number(pick(raw, 'activation_count', 'activationCount', 'ActivationCount') || 0),
extractCount: Number(pick(raw, 'extract_count', 'extractCount', 'ExtractCount') || 0),
lastActivatedAt: formatTime(pick(raw, 'last_activated_at', 'lastActivatedAt', 'activated_at')),
lastExtractedAt: formatTime(pick(raw, 'last_extracted_at', 'lastExtractedAt', 'extracted_at')),
expiredAt: formatTime(pick(raw, 'expired_at', 'expiredAt', 'expire_time', 'expireTime')),
createdAt: formatTime(pick(raw, 'create_time', 'created_at', 'createdAt', 'CreatedAt')),
remark: pick(raw, 'remark', 'Remark'),
raw,
};
}
function normalizeRecord(raw: any): EquipmentRow {
return {
id: pick(raw, 'id', 'ID', 'Id'),
status: pick(raw, 'status', 'Status', 'result', 'Result'),
account: pick(raw, 'account', 'Account', 'email', 'Email'),
platform: pick(raw, 'platform', 'Platform', 'source', 'Source'),
type: pick(raw, 'type', 'Type', 'data_type', 'dataType'),
content: pick(raw, 'content', 'Content', 'extract_content', 'extractContent', 'token', 'Token'),
ip: pick(raw, 'ip', 'IP', 'client_ip', 'clientIp'),
clientVersion: pick(raw, 'client_version', 'clientVersion', 'version', 'Version'),
createdAt: formatTime(pick(raw, 'create_time', 'created_at', 'createdAt', 'CreatedAt')),
remark: pick(raw, 'remark', 'Remark', 'message', 'Message'),
raw,
};
}
function statusLabel(status: string) {
return statusMap[status]?.label || status || '-';
}
function statusTagType(status: string) {
return statusMap[status]?.type || 'info';
}
function resetQuery() {
query.keyword = '';
query.status = '';
query.os = '';
}
function handleSelectionChange(rows: EquipmentRow[]) {
selectedRows.value = rows;
}
async function fetchList() {
loading.value = true;
try {
const res = await getCursorEquipmentList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: query.keyword || undefined,
status: query.status || undefined,
os: query.os || undefined,
});
if (res?.code !== 200) {
ElMessage.error(res?.msg || '获取设备列表失败');
return;
}
const list = Array.isArray(res?.data?.list) ? res.data.list : Array.isArray(res?.data) ? res.data : [];
tableData.value = list.map(normalizeRow);
total.value = Number(res?.data?.total || list.length || 0);
} finally {
loading.value = false;
}
}
async function openDetail(row: EquipmentRow) {
loading.value = true;
try {
const res = await getCursorEquipmentDetail(row.id);
if (res?.code === 200) {
currentRow.value = normalizeRow(res.data || row.raw || row);
} else {
currentRow.value = row;
ElMessage.warning(res?.msg || '详情接口异常,已展示列表数据');
}
detailVisible.value = true;
} finally {
loading.value = false;
}
}
function openAdd() {
currentRow.value = null;
editVisible.value = true;
}
function openEdit(row: EquipmentRow) {
currentRow.value = row;
editVisible.value = true;
}
function openDelete(row: EquipmentRow) {
currentRow.value = row;
deleteVisible.value = true;
}
async function handleSave(payload: EquipmentRow) {
actionLoading.value = true;
try {
const api = payload.id ? updateCursorEquipment : addCursorEquipment;
const res = await api(payload);
if (res?.code !== 200) {
ElMessage.error(res?.msg || '保存失败');
return;
}
ElMessage.success(payload.id ? '设备已更新' : '设备已新增');
editVisible.value = false;
await fetchList();
} finally {
actionLoading.value = false;
}
}
async function handleDelete() {
if (!currentRow.value?.id) return;
actionLoading.value = true;
try {
const res = await deleteCursorEquipment(currentRow.value.id);
if (res?.code !== 200) {
ElMessage.error(res?.msg || '删除失败');
return;
}
ElMessage.success('设备已删除');
deleteVisible.value = false;
await fetchList();
} finally {
actionLoading.value = false;
}
}
async function handleActivate(row: EquipmentRow) {
try {
await ElMessageBox.confirm(`确认激活设备「${row.name || row.deviceNo || row.id}」?`, '激活设备', {
type: 'info',
confirmButtonText: '确认激活',
cancelButtonText: '取消',
});
} catch {
return;
}
loading.value = true;
try {
const res = await activateCursorEquipment({ id: row.id });
if (res?.code !== 200) {
ElMessage.error(res?.msg || '激活失败');
return;
}
ElMessage.success('设备已激活');
await fetchList();
} finally {
loading.value = false;
}
}
function openActivationRecords(row: EquipmentRow) {
currentRow.value = row;
activationState.page = 1;
activationState.records = [];
activationState.total = 0;
activationVisible.value = true;
}
function openExtractRecords(row: EquipmentRow) {
currentRow.value = row;
extractState.page = 1;
extractState.records = [];
extractState.total = 0;
extractVisible.value = true;
}
async function fetchActivationRecords() {
if (!currentRow.value?.id) return;
activationState.loading = true;
try {
const res = await getCursorEquipmentActivationRecords({
equipmentId: currentRow.value.id,
page: activationState.page,
pageSize: activationState.pageSize,
});
if (res?.code !== 200) {
ElMessage.error(res?.msg || '获取激活记录失败');
return;
}
const list = Array.isArray(res?.data?.list) ? res.data.list : Array.isArray(res?.data) ? res.data : [];
activationState.records = list.map(normalizeRecord);
activationState.total = Number(res?.data?.total || list.length || 0);
} finally {
activationState.loading = false;
}
}
async function fetchExtractRecords() {
if (!currentRow.value?.id) return;
extractState.loading = true;
try {
const res = await getCursorEquipmentExtractRecords({
equipmentId: currentRow.value.id,
page: extractState.page,
pageSize: extractState.pageSize,
});
if (res?.code !== 200) {
ElMessage.error(res?.msg || '获取提取记录失败');
return;
}
const list = Array.isArray(res?.data?.list) ? res.data.list : Array.isArray(res?.data) ? res.data : [];
extractState.records = list.map(normalizeRecord);
extractState.total = Number(res?.data?.total || list.length || 0);
} finally {
extractState.loading = false;
}
}
function updateDeviceType() {
isMobile.value = window.innerWidth <= 768;
}
onMounted(() => {
updateDeviceType();
window.addEventListener('resize', updateDeviceType);
fetchList();
});
onUnmounted(() => {
window.removeEventListener('resize', updateDeviceType);
});
</script>
<template>
<div class="cursor-equipment-page">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>设备管理Cursor</span>
<el-button type="primary" @click="openAdd">新增设备</el-button>
</div>
</template>
<div class="summary-grid">
<div v-for="item in summary" :key="item.label" class="summary-card">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value" :class="`is-${item.type}`">{{ item.value }}</div>
</div>
</div>
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="query.keyword"
placeholder="搜索设备名称 / 编号 / 机器码 / 账号"
clearable
class="w-300"
/>
<el-select v-model="query.status" placeholder="设备状态" clearable class="w-140">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="query.os" placeholder="系统平台" clearable class="w-140">
<el-option v-for="item in osOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button @click="resetQuery">重置</el-button>
</div>
<div class="toolbar-right">
<el-button :loading="loading" @click="fetchList">刷新</el-button>
</div>
</div>
<div class="table-scroll">
<el-table
v-loading="loading"
class="equipment-table"
:data="tableData"
border
stripe
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="52" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="设备信息" min-width="220">
<template #default="{ row }">
<div class="device-name">{{ row.name || '-' }}</div>
<div class="device-no">编号{{ row.deviceNo || '-' }}</div>
</template>
</el-table-column>
<el-table-column prop="machineCode" label="机器码" min-width="220" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="os" label="系统" width="100" align="center">
<template #default="{ row }">{{ row.os || '-' }}</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="110" align="center">
<template #default="{ row }">{{ row.version || '-' }}</template>
</el-table-column>
<el-table-column prop="account" label="绑定账号" min-width="160" show-overflow-tooltip />
<el-table-column prop="owner" label="归属用户" min-width="130" show-overflow-tooltip />
<el-table-column label="激活/提取" width="120" align="center">
<template #default="{ row }">
<div class="count-line">
<span>激活 {{ row.activationCount }}</span>
<span>提取 {{ row.extractCount }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="lastActivatedAt" label="最后激活" width="180">
<template #default="{ row }">{{ row.lastActivatedAt || '-' }}</template>
</el-table-column>
<el-table-column prop="expiredAt" label="过期时间" width="180">
<template #default="{ row }">{{ row.expiredAt || '-' }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="310" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="warning" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.status !== 'active'" link type="success" @click="handleActivate(row)">激活</el-button>
<el-button link type="info" @click="openActivationRecords(row)">激活记录</el-button>
<el-button link type="info" @click="openExtractRecords(row)">提取记录</el-button>
<el-button link type="danger" @click="openDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
background
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
:page-sizes="[20, 50, 100]"
:total="total"
/>
</div>
</el-card>
<DetailDialog v-model="detailVisible" :row="currentRow" />
<EditDialog
v-model="editVisible"
:row="currentRow"
:loading="actionLoading"
@submit="handleSave"
/>
<DeleteDialog
v-model="deleteVisible"
:row="currentRow"
:loading="actionLoading"
@confirm="handleDelete"
/>
<ActivationRecords
v-model="activationVisible"
v-model:page="activationState.page"
v-model:page-size="activationState.pageSize"
:row="currentRow"
:loading="activationState.loading"
:records="activationState.records"
:total="activationState.total"
@refresh="fetchActivationRecords"
/>
<ExtractRecords
v-model="extractVisible"
v-model:page="extractState.page"
v-model:page-size="extractState.pageSize"
:row="currentRow"
:loading="extractState.loading"
:records="extractState.records"
:total="extractState.total"
@refresh="fetchExtractRecords"
/>
</div>
</template>
<style lang="less" scoped>
.cursor-equipment-page {
padding: 12px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fbfcff;
}
.summary-label {
color: #909399;
font-size: 13px;
margin-bottom: 8px;
}
.summary-value {
font-size: 24px;
font-weight: 700;
color: #409eff;
&.is-success {
color: #67c23a;
}
&.is-info {
color: #909399;
}
&.is-danger {
color: #f56c6c;
}
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.w-300 {
width: 300px;
}
.w-140 {
width: 140px;
}
.table-scroll {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.equipment-table {
min-width: 1280px;
}
.device-name {
font-weight: 600;
color: #303133;
line-height: 1.6;
}
.device-no {
color: #909399;
font-size: 12px;
line-height: 1.5;
}
.count-line {
display: flex;
flex-direction: column;
gap: 2px;
color: #606266;
font-size: 12px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
@media (max-width: 768px) {
.cursor-equipment-page {
padding: 8px;
}
.card-header {
align-items: flex-start;
flex-direction: column;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
.toolbar-left,
.toolbar-right,
.w-300,
.w-140 {
width: 100%;
}
.toolbar-right .el-button {
width: 100%;
}
.pager {
justify-content: center;
}
}
</style>