platform-vue/src/views/accountpool/cursor/index.vue
2026-06-15 23:43:55 +08:00

1310 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import Edit from "./components/edit.vue";
import DetailDialog from "./components/detail.vue";
import ExtractDialog from "./components/extract.vue";
import ReplenishDialog from "./components/replenish.vue";
import {
addAccountPool,
batchAddAccountPool,
extractAccountPool,
getAccountPoolDetail,
getAccountPoolList,
updateAccountPoolRemark,
replenishAccountPool,
probeAccountPoolToken,
} from "@/api/accountPool";
const moduleKey = "cursor";
const loading = ref(false);
const editVisible = ref(false);
const editMode = ref("single");
const detailVisible = ref(false);
const extractVisible = ref(false);
const extractTargetRow = ref(null);
const batchExtractVisible = ref(false);
const batchExtractForm = reactive({ platform: "local", remark: "" });
const replenishVisible = ref(false);
const replenishForm = reactive({ type: "tk", platform: "local", remark: "" });
const apiDocVisible = ref(false);
const patchVisible = ref(false);
const query = reactive({
keyword: "",
status: "",
usable: "",
});
const activeTypeTab = ref("all");
const extractForm = reactive({
platform: "local",
type: "account",
remark: "",
replenish: false,
});
const tableData = ref([]);
const total = ref(0);
const selectedRows = ref([]);
const detailRow = ref(null);
const detailRemarkSaving = ref(false);
const probeLoadingId = ref(null);
const isMobile = ref(false);
const pagination = reactive({
page: 1,
pageSize: 20,
});
/** 跳转未提取末页时跳过 watcher避免先被重置到第 1 页 */
const skipWatchFetchDuringUnusedJump = ref(false);
const pagedList = computed(() => tableData.value);
function resetQuery() {
query.keyword = "";
query.status = "";
query.usable = "";
}
const typeTabs = computed(() => {
return [
{ label: "全部", value: "all" },
{ label: "账号密码", value: "account" },
{ label: "账号密码+Token", value: "account_tk" },
{ label: "Token", value: "tk" },
];
});
watch(
() => [query.keyword, query.status, query.usable, activeTypeTab.value],
() => {
if (skipWatchFetchDuringUnusedJump.value) return;
pagination.page = 1;
fetchList();
},
);
watch(
() => [pagination.page, pagination.pageSize],
() => {
if (skipWatchFetchDuringUnusedJump.value) return;
fetchList();
},
);
function openAddDialog(mode = "single") {
editMode.value = mode;
editVisible.value = true;
}
function nowText() {
const d = new Date();
const p = (v) => String(v).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
async function saveRows(rows) {
if (!rows.length) return;
if (rows.length === 1) {
await addAccountPool(moduleKey, rows[0]);
return;
}
await batchAddAccountPool(moduleKey, rows);
}
async function handleEditSubmit(payload) {
loading.value = true;
try {
await saveRows(payload.rows || []);
ElMessage.success(
payload.mode === "batch" ? "批量添加成功" : "账号添加成功",
);
await fetchList();
} finally {
loading.value = false;
}
}
function handleSelectionChange(rows) {
selectedRows.value = rows;
}
function openDetail(row) {
loading.value = true;
getAccountPoolDetail(moduleKey, row.id)
.then((res) => {
if (res?.code !== 200) {
ElMessage.error(res?.msg || "获取详情失败");
return;
}
detailRow.value = normalizeRow(res.data || {});
detailVisible.value = true;
})
.finally(() => {
loading.value = false;
});
}
function openExtractDialog() {
extractTargetRow.value = null;
extractForm.platform = "local";
extractForm.type = "account";
extractForm.replenish = false;
extractVisible.value = true;
}
function openExtractByRow(row) {
extractTargetRow.value = row;
extractForm.platform = "local";
extractForm.type = row.type;
extractForm.remark = row.remark || "";
extractForm.replenish = false;
extractVisible.value = true;
}
function openPatchDialog() {
patchVisible.value = true;
}
function buildCopyTextByRow(row) {
const parts = [];
if (row?.account) parts.push(row.account);
if (row?.password) parts.push(row.password);
if (row?.token) parts.push(row.token);
return parts.join('\n');
}
function rowToText(row) {
const parts = [];
if (row?.account) parts.push(row.account);
if (row?.password) parts.push(row.password);
if (row?.token) parts.push(row.token);
return parts.join(' / ');
}
async function copyToClipboard(text) {
if (!text) {
ElMessage.warning('无可复制内容');
return false;
}
try {
await navigator.clipboard.writeText(text);
ElMessage.success('已复制');
return true;
} catch (e) {
ElMessage.error('复制失败,请检查浏览器权限');
return false;
}
}
async function handleExtract() {
loading.value = true;
try {
const target = extractTargetRow.value;
if (!target) {
ElMessage.warning("未找到提取目标");
return;
}
const res = await extractAccountPool(moduleKey, {
id: target.id,
type: target.type,
platform: extractForm.platform,
remark: extractForm.remark || "",
replenish: !!extractForm.replenish,
});
if (res?.code !== 200) {
ElMessage.error(res?.msg || "提取失败");
return;
}
ElMessage.success("提取成功");
extractVisible.value = false;
const row = normalizeRow(res.data || {});
navigator.clipboard.writeText(rowToText(row)).catch(() => {});
await fetchList();
} finally {
loading.value = false;
}
}
async function handleSaveRemark(payload) {
if (!payload?.id) return;
detailRemarkSaving.value = true;
try {
const res = await updateAccountPoolRemark(moduleKey, payload);
if (res?.code !== 200) {
ElMessage.error(res?.msg || "备注更新失败");
return;
}
ElMessage.success("备注已更新");
if (detailRow.value?.id === payload.id) {
detailRow.value = { ...detailRow.value, remark: payload.remark || "" };
}
await fetchList();
} finally {
detailRemarkSaving.value = false;
}
}
function markExtractForSelected() {
if (!selectedRows.value.length) {
ElMessage.warning("请先选择数据");
return;
}
batchExtractForm.platform = "local";
batchExtractForm.remark = "";
batchExtractVisible.value = true;
}
async function handleBatchExtract() {
loading.value = true;
try {
const results = await Promise.all(
selectedRows.value.map((row) =>
extractAccountPool(moduleKey, {
id: row.id,
type: row.type,
platform: batchExtractForm.platform,
remark: batchExtractForm.remark || "",
}),
),
);
const succeeded = results
.filter((r) => r?.code === 200)
.map((r) => normalizeRow(r.data || {}));
const failCount = results.length - succeeded.length;
if (failCount > 0) {
ElMessage.warning(`${succeeded.length} 条成功,${failCount} 条失败`);
} else {
ElMessage.success("批量提取成功");
}
batchExtractVisible.value = false;
if (succeeded.length) {
const text = succeeded.map(rowToText).filter(Boolean).join("\n");
navigator.clipboard.writeText(text).catch(() => {});
}
fetchList();
} finally {
loading.value = false;
}
}
async function handleReplenish() {
loading.value = true;
try {
const res = await replenishAccountPool(moduleKey, {
type: replenishForm.type,
platform: replenishForm.platform,
remark: replenishForm.remark || "",
});
if (res?.code !== 200) {
ElMessage.error(res?.msg || "补号失败");
return;
}
ElMessage.success("补号成功,已复制到剪贴板");
replenishVisible.value = false;
const row = normalizeRow(res.data || {});
navigator.clipboard.writeText(rowToText(row)).catch(() => {});
await fetchList();
} finally {
loading.value = false;
}
}
function typeText(type) {
if (type === "account") return "账号密码";
if (type === "account_tk") return "账号密码+Token";
return "Token";
}
function extractStatusLabel(row) {
if (row?.extractStatus === 2) return "补号";
if (row?.extracted) return "已提取";
return "未提取";
}
function extractStatusTagType(row) {
if (row?.extractStatus === 2) return "warning";
if (row?.extracted) return "success";
return "info";
}
const tooltipOpts = {
popperClass: 'pool-tooltip',
popperStyle: { maxWidth: '600px', wordBreak: 'break-all', whiteSpace: 'pre-wrap' },
};
const PLATFORM_MAP = {
local: { label: '本地', type: 'info' },
xianyu: { label: '闲鱼', type: 'warning' },
pinduoduo: { label: '拼多多', type: 'danger' },
jingdong: { label: '京东', type: 'primary' },
douyin: { label: '抖音', type: 'success' },
};
function platformText(platform) {
return PLATFORM_MAP[platform]?.label || platform || "-";
}
function platformTagType(platform) {
return PLATFORM_MAP[platform]?.type || "info";
}
function isUsedLabel(isUsed) {
if (isUsed === null || isUsed === undefined) return "未探测";
const n = Number(isUsed);
if (n === 1) return "可用";
if (n === 0) return "已用完";
return String(isUsed);
}
function isUsedTagType(isUsed) {
const n = Number(isUsed);
if (n === 1) return "success";
if (n === 0) return "danger";
return "info";
}
function decodeJwtPayload(rawToken) {
const token = String(rawToken || "").trim();
if (!token) return null;
const pureToken = token.includes("::") ? token.split("::").pop().trim() : token;
const parts = pureToken.split(".");
if (parts.length < 2) return null;
try {
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
const json = decodeURIComponent(
atob(padded)
.split("")
.map((ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join(""),
);
return JSON.parse(json);
} catch {
return null;
}
}
function formatTimestamp(value) {
if (!value && value !== 0) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
const p = (v) => 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 resolveAccessTokenExpireInfo(token) {
const payload = decodeJwtPayload(token);
const expRaw = payload?.exp;
const expSeconds = Number(expRaw);
if (!Number.isFinite(expSeconds) || expSeconds <= 0) {
return {
accessTokenExpireAt: "",
accessTokenExpireStatus: "unknown",
accessTokenExpireText: "无法解析",
};
}
const expireAt = formatTimestamp(expSeconds * 1000);
const expired = expSeconds * 1000 <= Date.now();
return {
accessTokenExpireAt: expireAt,
accessTokenExpireStatus: expired ? "expired" : "valid",
accessTokenExpireText: expired ? "已失效" : expireAt,
};
}
function accessTokenExpireTagType(status) {
if (status === "valid") return "success";
if (status === "expired") return "danger";
return "info";
}
function normalizeRow(raw) {
const pick = (...keys) => {
for (const key of keys) {
if (raw?.[key] !== undefined && raw?.[key] !== null) return raw[key];
}
return "";
};
// 指针类型字段(可能为 null单独处理
const pickNullable = (...keys) => {
for (const key of keys) {
if (raw?.[key] !== undefined) return raw[key] ?? null;
}
return null;
};
const formatTime = (val) => {
if (!val) return "";
const d = new Date(val);
if (isNaN(d)) return val;
const p = (v) => String(v).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
const st = Number(pick("is_extracted", "isExtracted", "IsExtracted"));
const extractStatus = Number.isFinite(st) ? st : 0;
const isUsedRaw = pickNullable("is_used", "isUsed", "IsUsed");
const isUsedNum =
isUsedRaw === null || isUsedRaw === undefined || isUsedRaw === ""
? null
: Number(isUsedRaw);
const token = pick("token", "Token");
const expireInfo = resolveAccessTokenExpireInfo(token);
return {
id: pick("id", "Id", "ID"),
type: pick("data_type", "dataType", "type"),
account: pick("account", "Account"),
password: pick("password", "Password"),
token,
remark: pick("remark", "Remark"),
extractStatus,
extracted: extractStatus !== 0,
isUsed: Number.isFinite(isUsedNum) ? isUsedNum : null,
accessTokenExpireAt: expireInfo.accessTokenExpireAt,
accessTokenExpireStatus: expireInfo.accessTokenExpireStatus,
accessTokenExpireText: expireInfo.accessTokenExpireText,
extractedAt: formatTime(pickNullable("extracted_time", "extractedAt")),
extractedPlatform: pickNullable("extracted_platform", "extractedPlatform"),
createdAt: formatTime(pick("create_time", "createdAt")),
};
}
async function fetchList() {
loading.value = true;
try {
const res = await getAccountPoolList(moduleKey, {
page: pagination.page,
pageSize: pagination.pageSize,
keyword: query.keyword || undefined,
status: query.status || undefined,
usable: query.usable === "1" || query.usable === "0" ? query.usable : undefined,
type: activeTypeTab.value === "all" ? undefined : activeTypeTab.value,
});
if (res?.code !== 200) {
ElMessage.error(res?.msg || "获取列表失败");
return;
}
const list = Array.isArray(res?.data?.list) ? res.data.list : [];
tableData.value = list.map(normalizeRow);
total.value = Number(res?.data?.total || 0);
} finally {
loading.value = false;
}
}
/** 筛选「未提取」并翻到该条件下的最后一页(当前关键词、账号类型 tab 不变) */
async function jumpToLastUnusedPage() {
const type = activeTypeTab.value === "all" ? undefined : activeTypeTab.value;
const res = await getAccountPoolList(moduleKey, {
page: 1,
pageSize: pagination.pageSize,
keyword: query.keyword || undefined,
status: "unused",
usable: query.usable === "1" || query.usable === "0" ? query.usable : undefined,
type,
});
if (res?.code !== 200) {
ElMessage.error(res?.msg || "获取列表失败");
return;
}
const cnt = Number(res?.data?.total || 0);
if (cnt === 0) {
ElMessage.warning("暂无未提取数据");
return;
}
const lastPage = Math.max(1, Math.ceil(cnt / pagination.pageSize));
skipWatchFetchDuringUnusedJump.value = true;
pagination.page = lastPage;
query.status = "unused";
await nextTick();
skipWatchFetchDuringUnusedJump.value = false;
await fetchList();
ElMessage.success(`已跳转未提取第 ${lastPage} 页(共 ${cnt} 条)`);
}
function updateDeviceType() {
isMobile.value = window.innerWidth <= 768;
}
onMounted(() => {
updateDeviceType();
window.addEventListener('resize', updateDeviceType);
fetchList();
});
onUnmounted(() => {
window.removeEventListener('resize', updateDeviceType);
});
// ---- 接口说明数据 ----
const BASE_URL = "https://api.yunzer.cn";
const paramDocs = [
{ name: 'type', required: true, desc: '来源平台,用于标记本次提取来自哪个渠道', values: 'xianyu / taobao / pinduoduo / jingdong / local' },
{ name: 'module', required: true, desc: '号池模块,指定从哪个产品的号池提取', values: 'cursor / windsurf / krio' },
{ name: 'data_type', required: false, desc: '账号类型,不传则提取任意类型', values: 'account / tk / account_tk' },
];
const platformDocs = [
{ value: 'xianyu', label: '闲鱼', desc: '闲鱼平台发货调用' },
{ value: 'pinduoduo', label: '拼多多', desc: '拼多多平台发货调用' },
{ value: 'jingdong', label: '京东', desc: '京东平台发货调用' },
{ value: 'douyin', label: '抖音', desc: '抖音平台发货调用' },
{ value: 'local', label: '本地', desc: '本地手动调用' },
];
const moduleDocs = [
{ value: "cursor", label: "Cursor", desc: "Cursor 号池" },
{ value: "windsurf", label: "Windsurf", desc: "Windsurf 号池" },
{ value: "krio", label: "Krio", desc: "Krio 号池" },
];
const examples = [
{
label: "闲鱼 · 提取 Cursor Token",
url: `${BASE_URL}/api/getcard?type=xianyu&module=cursor&data_type=tk`,
},
{
label: "拼多多 · 提取 Cursor 账号密码",
url: `${BASE_URL}/api/getcard?type=pinduoduo&module=cursor&data_type=account`,
},
{
label: "京东 · 提取 Windsurf 任意类型",
url: `${BASE_URL}/api/getcard?type=jingdong&module=windsurf`,
},
{
label: "抖音 · 提取 Krio Token",
url: `${BASE_URL}/api/getcard?type=douyin&module=krio&data_type=tk`,
},
];
const successResp = `// 纯 Token 类型data_type=tk
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// 账号密码类型data_type=account
账号user@example.com / 密码your_password
// 账号密码+Token 类型data_type=account_tk
账号user@example.com / 密码your_password / TokeneyJhbGciOiJIUzI1NiIs...`;
const errorResp = `// 无可用卡密
{ "code": 404, "msg": "暂无可用卡密" }
// 参数错误
{ "code": 400, "msg": "缺少参数 type来源平台" }`;
function copyText(text) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success("已复制");
});
}
function copyCardInfo(row) {
const parts = [];
if (row.account) parts.push(row.account);
if (row.password) parts.push(row.password);
if (row.token) parts.push(row.token);
if (!parts.length) { ElMessage.warning('无可复制内容'); return; }
navigator.clipboard.writeText(parts.join('\n')).then(() => {
ElMessage.success('已复制');
});
}
const CURSOR_PRO_LIMIT_TEXT = 'Get Cursor Pro for more Agent usage, unlimited Tab, and more.';
function formatCursorProbeDialogText(d) {
// 1. 优先以新版后端的 ok 字段(也就是底层探针的交叉判定结论)为核心准则
if (d && typeof d.ok === 'boolean') {
return d.ok ? '该TOKEN可用' : `该TOKEN已用完 (${d.detail || '额度枯竭'})`;
}
// 2. 兼容旧数据的兜底检测
const CURSOR_PRO_LIMIT_TEXT = 'Get Cursor Pro for more Agent usage, unlimited Tab, and more.';
const detail = String(d?.detail || '');
const rawPreview = String(d?.rawPreview || '');
if (detail.includes(CURSOR_PRO_LIMIT_TEXT) || rawPreview.includes(CURSOR_PRO_LIMIT_TEXT)) {
return '该TOKEN已用完';
}
return '该TOKEN可用';
}
async function handleProbeToken(row) {
if (!row?.token) {
ElMessage.warning('该行无 Token');
return;
}
probeLoadingId.value = row.id;
try {
const res = await probeAccountPoolToken(moduleKey, {
id: row.id,
accessToken: row.token,
});
if (res?.code !== 200) {
ElMessage.error(res?.msg || '探测失败');
return;
}
const d = res?.data || {};
const text = formatCursorProbeDialogText(d);
try {
await ElMessageBox({
title: '检测结果',
message: h('div', { class: 'cursor-probe-result' }, text),
confirmButtonText: '关闭',
customClass: 'cursor-probe-dialog',
closeOnClickModal: true,
});
} catch {
/* 用户关闭弹窗 */
}
await fetchList();
} catch {
ElMessage.error('探测请求失败');
} finally {
probeLoadingId.value = null;
}
}
async function handleBatchProbe() {
if (!selectedRows.value.length) {
ElMessage.warning("请先选择数据");
return;
}
const rows = selectedRows.value.filter((r) => r?.token);
if (!rows.length) {
ElMessage.warning("所选行均无 Token无法检测");
return;
}
const skipped = selectedRows.value.length - rows.length;
const skipHint =
skipped > 0 ? `(已跳过 ${skipped} 条无 Token` : "";
try {
await ElMessageBox.confirm(
`将对 ${rows.length} 条 Token进行检测是否继续${skipHint}`,
"批量检测",
{ type: "info", confirmButtonText: "开始", cancelButtonText: "取消" },
);
} catch {
return;
}
loading.value = true;
let ok = 0;
let fail = 0;
try {
for (const row of rows) {
try {
const res = await probeAccountPoolToken(moduleKey, {
id: row.id,
accessToken: row.token,
});
if (res?.code === 200) {
ok += 1;
} else {
fail += 1;
}
} catch {
fail += 1;
}
}
if (fail > 0) {
ElMessage.warning(`批量检测完成:成功 ${ok} 条,失败 ${fail}`);
} else {
ElMessage.success(`批量检测完成:共 ${ok}`);
}
await fetchList();
} finally {
loading.value = false;
}
}
// async function handleBatchProbeExpireTime() {
// if (!selectedRows.value.length) {
// ElMessage.warning("请先选择数据");
// return;
// }
// const rows = selectedRows.value.filter((r) => r?.token);
// if (!rows.length) {
// ElMessage.warning("所选行均无 Token无法检测时间");
// return;
// }
// const skipped = selectedRows.value.length - rows.length;
// const validRows = rows.map((row) => ({
// ...row,
// ...resolveAccessTokenExpireInfo(row.token),
// }));
// const validCount = validRows.filter((row) => row.accessTokenExpireStatus === "valid").length;
// const expiredCount = validRows.filter((row) => row.accessTokenExpireStatus === "expired").length;
// const unknownCount = validRows.filter((row) => row.accessTokenExpireStatus === "unknown").length;
// tableData.value = tableData.value.map((item) => {
// const found = validRows.find((row) => row.id === item.id);
// return found
// ? {
// ...item,
// accessTokenExpireAt: found.accessTokenExpireAt,
// accessTokenExpireStatus: found.accessTokenExpireStatus,
// accessTokenExpireText: found.accessTokenExpireText,
// }
// : item;
// });
// const lines = validRows.slice(0, 20).map((row) => `ID ${row.id}${row.accessTokenExpireText}`);
// if (validRows.length > 20) {
// lines.push(`...其余 ${validRows.length - 20} 条未展开`);
// }
// if (skipped > 0) {
// lines.push(`已跳过无 Token ${skipped} 条`);
// }
// await ElMessageBox({
// title: "批量时间检测结果",
// message: h("div", { class: "cursor-probe-result cursor-expire-result" }, [
// h("div", `有效 ${validCount} 条,已失效 ${expiredCount} 条,无法解析 ${unknownCount} 条`),
// h("pre", { class: "cursor-expire-result-pre" }, lines.join("\n")),
// ]),
// confirmButtonText: "关闭",
// customClass: "cursor-probe-dialog",
// closeOnClickModal: true,
// });
// }
</script>
<template>
<div class="account-pool-page">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>号池管理Cursor</span>
</div>
</template>
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="query.keyword"
placeholder="搜索账号 / token / 备注"
clearable
class="w-260"
/>
<el-select
v-model="query.status"
placeholder="提取状态"
clearable
class="w-140"
>
<el-option label="未提取" value="unused" />
<el-option label="已提取" value="extracted" />
</el-select>
<el-select
v-model="query.usable"
placeholder="探测可用"
clearable
class="w-140"
>
<el-option label="可用" value="1" />
<el-option label="已用完" value="0" />
</el-select>
<el-button
type="primary"
plain
title="按当前搜索与账号类型,筛选未提取并跳到最后一页"
@click="jumpToLastUnusedPage"
>
未提取末页
</el-button>
<el-button @click="resetQuery">重置</el-button>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="openAddDialog('single')">添加账号</el-button>
<el-button type="success" @click="openAddDialog('batch')">批量添加</el-button>
<el-button @click="replenishVisible = true">补号</el-button>
<el-button @click="markExtractForSelected">批量标记提取</el-button>
<el-button plain @click="handleBatchProbe">批量检测</el-button>
<!-- <el-button type="info" plain @click="handleBatchProbeExpireTime">批量时间检测</el-button> -->
<el-button @click="apiDocVisible = true">接口说明</el-button>
</div>
</div>
<el-tabs v-model="activeTypeTab" class="type-tabs">
<el-tab-pane
v-for="tab in typeTabs"
:key="tab.value"
:label="tab.label"
:name="tab.value"
/>
</el-tabs>
<div class="table-scroll">
<el-table
class="pool-table"
:data="pagedList"
border
stripe
style="width: 100%"
:loading="loading"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="52" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="账号类型" width="160" align="center">
<template #default="{ row }">
<el-tag>{{ typeText(row.type) }}</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="account" label="账号" min-width="180" show-overflow-tooltip :tooltip-options="tooltipOpts" />
<el-table-column prop="password" label="密码" min-width="160" show-overflow-tooltip :tooltip-options="tooltipOpts">
<template #default="{ row }">{{ row.password || '-' }}</template>
</el-table-column>
<el-table-column label="Token" min-width="200" show-overflow-tooltip :tooltip-options="tooltipOpts">
<template #default="{ row }">{{ row.token || '-' }}</template>
</el-table-column> -->
<el-table-column label="提取状态" width="100">
<template #default="{ row }">
<el-tag :type="extractStatusTagType(row)">
{{ extractStatusLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="探测可用" width="100" align="center">
<template #default="{ row }">
<el-tag :type="isUsedTagType(row.isUsed)" size="small">
{{ isUsedLabel(row.isUsed) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="accessToken失效时间" width="190" align="center">
<template #default="{ row }">
<el-tag :type="accessTokenExpireTagType(row.accessTokenExpireStatus)" size="small">
{{ row.accessTokenExpireText || "-" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="提取平台" width="120">
<template #default="{ row }">
<el-tag
v-if="row.extractedPlatform"
:type="platformTagType(row.extractedPlatform)"
size="small"
>
{{ platformText(row.extractedPlatform) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="extractedAt" label="提取时间" width="180" />
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip :tooltip-options="tooltipOpts" />
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.token"
link
type="info"
:loading="probeLoadingId === row.id"
@click="handleProbeToken(row)"
>检测</el-button
>
<el-button link type="primary" @click="openDetail(row)"
>详情</el-button
>
<el-button
v-if="!row.extractedAt && !row.extracted"
link
type="warning"
@click="openExtractByRow(row)"
>提取</el-button
>
<el-button
v-if="row.extracted"
link
type="success"
@click="copyCardInfo(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, prev, pager, next, jumper'"
:page-sizes="[20, 50, 100]"
:total="total"
/>
</div>
</el-card>
<Edit v-model="editVisible" :mode="editMode" @submit="handleEditSubmit" />
<DetailDialog
v-model="detailVisible"
:row="detailRow"
:save-loading="detailRemarkSaving"
@save-remark="handleSaveRemark"
/>
<ExtractDialog
v-model="extractVisible"
:loading="loading"
:type="extractForm.type"
:platform="extractForm.platform"
:remark="extractForm.remark"
:replenish="extractForm.replenish"
:platform-map="PLATFORM_MAP"
@update:platform="(v) => (extractForm.platform = v)"
@update:remark="(v) => (extractForm.remark = v)"
@update:replenish="(v) => (extractForm.replenish = v)"
@confirm="handleExtract"
/>
<ReplenishDialog
v-model="replenishVisible"
:loading="loading"
:type="replenishForm.type"
:platform="replenishForm.platform"
:remark="replenishForm.remark"
:platform-map="PLATFORM_MAP"
@update:type="(v) => (replenishForm.type = v)"
@update:platform="(v) => (replenishForm.platform = v)"
@update:remark="(v) => (replenishForm.remark = v)"
@confirm="handleReplenish"
/>
<!-- 接口说明抽屉 -->
<el-drawer
v-model="apiDocVisible"
title="提卡接口说明"
size="560px"
direction="rtl"
>
<div class="api-doc">
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
该接口为对外公开接口,无需登录认证,每次调用自动提取一条未使用的卡密并标记为已提取(不可重复)。
</el-alert>
<div class="doc-section">
<div class="doc-title">接口地址</div>
<el-tag type="success" class="method-tag">GET</el-tag>
<code class="url-code">https://api.yunzer.cn/api/getcard</code>
</div>
<div class="doc-section">
<div class="doc-title">请求参数</div>
<el-table :data="paramDocs" border size="small">
<el-table-column prop="name" label="参数名" width="120" />
<el-table-column
prop="required"
label="必填"
width="60"
align="center"
>
<template #default="{ row }">
<el-tag :type="row.required ? 'danger' : 'info'" size="small">
{{ row.required ? "是" : "否" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="desc" label="说明" />
<el-table-column prop="values" label="可选值" min-width="160" />
</el-table>
</div>
<div class="doc-section">
<div class="doc-title">调用示例</div>
<div v-for="ex in examples" :key="ex.label" class="example-item">
<div class="example-label">{{ ex.label }}</div>
<div class="example-url-wrap">
<code class="example-url">{{ ex.url }}</code>
<el-button
link
type="primary"
size="small"
@click="copyText(ex.url)"
>复制</el-button
>
</div>
</div>
</div>
<div class="doc-section">
<div class="doc-title">成功响应</div>
<pre class="code-block">{{ successResp }}</pre>
</div>
<div class="doc-section">
<div class="doc-title">失败响应</div>
<pre class="code-block">{{ errorResp }}</pre>
</div>
<div class="doc-section">
<div class="doc-title">支持的平台type 参数)</div>
<el-table :data="platformDocs" border size="small">
<el-table-column prop="value" label="type 值" width="130" />
<el-table-column prop="label" label="平台" width="100" />
<el-table-column prop="desc" label="说明" />
</el-table>
</div>
<div class="doc-section">
<div class="doc-title">支持的号池module 参数)</div>
<el-table :data="moduleDocs" border size="small">
<el-table-column prop="value" label="module 值" width="130" />
<el-table-column prop="label" label="号池" width="100" />
<el-table-column prop="desc" label="说明" />
</el-table>
</div>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.account-pool-page {
padding: 12px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.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-260 {
width: 260px;
}
.w-140 {
width: 140px;
}
.w-180 {
width: 180px;
}
.type-tabs {
margin-bottom: 12px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
.table-scroll {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-scroll :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.pool-table {
min-width: 980px;
}
@media (max-width: 768px) {
.account-pool-page {
padding: 8px;
}
.toolbar {
gap: 8px;
}
.toolbar-left,
.toolbar-right {
width: 100%;
gap: 8px;
}
.w-260,
.w-140 {
width: 100%;
}
.toolbar-right .el-button {
flex: 1 1 calc(50% - 8px);
min-width: 120px;
margin: 0;
}
.type-tabs :deep(.el-tabs__nav-wrap) {
overflow-x: auto;
overflow-y: hidden;
}
.pager {
justify-content: center;
}
}
/* 接口说明抽屉 */
.api-doc {
padding: 0 4px;
font-size: 13px;
}
.doc-section {
margin-bottom: 24px;
}
.doc-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 10px;
color: #303133;
border-left: 3px solid #409eff;
padding-left: 8px;
}
.method-tag {
margin-right: 8px;
vertical-align: middle;
}
.url-code {
background: #f5f7fa;
padding: 4px 10px;
border-radius: 4px;
font-size: 13px;
color: #e6a23c;
word-break: break-all;
}
.example-item {
margin-bottom: 10px;
}
.example-label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.example-url-wrap {
display: flex;
align-items: center;
gap: 8px;
background: #f5f7fa;
padding: 6px 10px;
border-radius: 4px;
}
.example-url {
flex: 1;
font-size: 12px;
color: #409eff;
word-break: break-all;
}
.code-block {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px 16px;
border-radius: 6px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.extract-result-item {
margin-bottom: 4px;
}
.result-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 6px 0;
font-size: 13px;
}
.result-label {
flex-shrink: 0;
width: 44px;
color: #909399;
}
.result-val {
flex: 1;
word-break: break-all;
color: #303133;
}
.token-val {
font-family: monospace;
font-size: 12px;
color: #409eff;
}
</style>
<style>
.pool-tooltip.el-popper {
max-width: 600px !important;
white-space: pre-wrap !important;
word-break: break-all !important;
}
.pool-tooltip .el-popper__arrow {
display: block;
}
/* Cursor 探测结果弹窗teleport 到 body需非 scoped */
.cursor-probe-dialog .el-message-box__message {
padding: 12px 8px 4px;
}
.cursor-probe-dialog .cursor-probe-result {
margin: 0;
text-align: center;
font-size: 16px;
line-height: 1.6;
font-weight: 600;
word-break: break-all;
}
.cursor-probe-dialog .cursor-expire-result {
text-align: left;
}
.cursor-probe-dialog .cursor-expire-result-pre {
margin: 12px 0 0;
padding: 10px 12px;
max-height: 320px;
overflow: auto;
background: #f5f7fa;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
</style>