platform-vue/src/views/accountpool/windsurf/index.vue
2026-05-05 23:55:17 +08:00

777 lines
26 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, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ElMessage } 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 PatchDialog from '../components/patch.vue';
import {
addAccountPool,
batchAddAccountPool,
extractAccountPool,
getAccountPoolDetail,
getAccountPoolList,
updateAccountPoolRemark,
replenishAccountPool,
probeAccountPoolToken,
} from '@/api/accountPool';
const moduleKey = "windsurf";
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: "" });
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: 30 });
const skipWatchFetchDuringUnusedJump = ref(false);
const pagedList = computed(() => tableData.value);
function resetQuery() {
query.keyword = "";
query.status = "";
}
const typeTabs = computed(() => [
{ label: "全部", value: "all" },
{ label: "账号密码", value: "account" },
{ label: "账号密码+Token", value: "account_tk" },
{ label: "Token", value: "tk" },
]);
watch(
() => [query.keyword, query.status, 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;
}
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 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');
}
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;
}
}
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 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";
}
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 normalizeRow(raw) {
const pick = (...keys) => {
for (const key of keys) {
if (raw?.[key] !== undefined && raw?.[key] !== null) return raw[key];
}
return "";
};
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;
return {
id: pick("id", "Id", "ID"),
type: pick("data_type", "dataType", "type"),
account: pick("account", "Account"),
password: pick("password", "Password"),
token: pick("token", "Token"),
remark: pick("remark", "Remark"),
extractStatus,
extracted: extractStatus !== 0,
extractedAt: formatTime(pickNullable("extracted_time", "extractedAt")),
extractedPlatform: pickNullable("extracted_platform", "extractedPlatform"),
createdAt: formatTime(pick("create_time", "createdAt")),
};
}
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";
}
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,
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;
}
}
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',
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} 条)`);
}
onMounted(() => { fetchList(); });
// ---- 接口说明数据 ----
const BASE_URL = "https://api.yunzer.cn";
const paramDocs = [
{ name: 'type', required: true, desc: '来源平台,用于标记本次提取来自哪个渠道', values: 'xianyu / pinduoduo / jingdong / douyin / 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: "闲鱼 · 提取 Windsurf Token",
url: `${BASE_URL}/api/getcard?type=xianyu&module=windsurf&data_type=tk`,
},
{
label: "拼多多 · 提取 Windsurf 账号密码",
url: `${BASE_URL}/api/getcard?type=pinduoduo&module=windsurf&data_type=account`,
},
{
label: "京东 · 提取 Cursor 任意类型",
url: `${BASE_URL}/api/getcard?type=jingdong&module=cursor`,
},
{
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('已复制'); });
}
async function handleProbeToken(row) {
if (!row?.token) {
ElMessage.warning('该行无 Token');
return;
}
probeLoadingId.value = row.id;
try {
const res = await probeAccountPoolToken(moduleKey, { id: row.id });
if (res?.code !== 200) {
ElMessage.error(res?.msg || '探测失败');
return;
}
const d = res?.data || {};
if (d.ok) {
ElMessage.success(d.detail || '官方接口响应正常');
} else {
ElMessage.error(d.detail || '不可用或校验失败');
}
} catch {
ElMessage.error('探测请求失败');
} finally {
probeLoadingId.value = null;
}
}
</script>
<template>
<div class="account-pool-page">
<el-card shadow="never">
<template #header>
<div class="card-header"><span>号池管理Windsurf</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-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 type="warning" @click="replenishVisible = true">补号</el-button>
<el-button @click="markExtractForSelected">批量标记提取</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 :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 prop="remark" label="备注" min-width="140" show-overflow-tooltip :tooltip-options="tooltipOpts" />
<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 prop="extractedAt" label="提取时间" width="180" />
<el-table-column label="提取平台" width="110">
<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 label="操作" width="300" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)"
>详情</el-button
>
<el-button
v-if="row.token"
link
type="info"
:loading="probeLoadingId === row.id"
@click="handleProbeToken(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="[30, 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; }
.type-tabs { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 14px; }
.table-scroll { width: 100%; overflow-x: hidden; }
.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; }
</style>
<style>
.pool-tooltip.el-popper {
max-width: 600px;
white-space: pre-wrap;
word-break: break-all;
}
</style>