Refactor frontend workspaces and centralize API contracts

Organize domain APIs and types under common/apis, shared components under named folders, and private workspace logic alongside each page.

Use Bearer sessions, explicit response envelopes, session-aware routing and streams, and configurable login captcha presentation for the deployed backend contract.

Preserve news, economic calendar, crawler, market, chat, Skills, settings, and demo trading workflows with responsive layouts and explicit configuration saves.

Validate with frontend lint, type checking, 22 test groups, production builds, and deployed desktop/mobile page checks.
This commit is contained in:
eezd committed 2026-10-26 11:39:52 +08:00
1 parent 77b33e5e77
commit bc480fb806
102 files changed
+4716 -1707

No files matched your search

@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { Exchange, ExchangeName } from "@@/apis/settings/type"
import type { FormInstance, FormRules } from "element-plus"
import { deleteExchangeApi, getExchangeApi, putExchangeApi, testExchangeApi } from "@@/apis/settings"
import { useSessionScope } from "@@/composables/useSessionScope"
import { formatTime } from "@@/utils/text"
const sessionScope = useSessionScope()
// ---------- 模拟盘 ----------
const exchange = ref<Exchange>()
const exchangeDialogVisible = ref(false)
const exchangeForm = ref({ api_key: "", api_secret: "" })
const exchangeSaving = ref(false)
const exchangeTesting = ref(false)
const exchangeFormRef = ref<FormInstance>()
const rules: FormRules = {
api_key: [{ required: true, whitespace: true, message: "请输入 API Key", trigger: "blur" }],
api_secret: [{ required: true, whitespace: true, message: "请输入 Secret Key", trigger: "blur" }]
}
const failed = ref(false)
const loading = ref(false)
async function loadExchange() {
loading.value = true
try {
exchange.value = await getExchangeApi()
failed.value = false
} catch (error) {
failed.value = true
throw error
} finally {
loading.value = false
}
}
async function changeExchange(value: ExchangeName) {
exchangeSaving.value = true
try {
exchange.value = await putExchangeApi({ exchange: value })
ElMessage.success("已切换模拟盘,另一家交易所的凭证和历史记录仍保留")
} finally {
exchangeSaving.value = false
}
}
function openExchangeEdit() {
exchangeForm.value = { api_key: "", api_secret: "" }
exchangeDialogVisible.value = true
}
async function saveExchange() {
if (exchangeSaving.value) return
exchangeSaving.value = true
try {
await exchangeFormRef.value!.validate()
exchange.value = await putExchangeApi({ exchange: exchange.value!.exchange, ...exchangeForm.value })
exchangeDialogVisible.value = false
ElMessage.success("已保存")
} finally {
exchangeSaving.value = false
}
}
async function removeExchange() {
await ElMessageBox.confirm("删除模拟盘 API key?", "删除确认", { type: "warning" })
sessionScope.assertCurrent()
exchange.value = await deleteExchangeApi()
ElMessage.success("已删除")
}
async function runExchangeTest() {
exchangeTesting.value = true
try {
const r = await testExchangeApi()
if (r.ok) {
const mode = r.dual_side_position ? "双向持仓(开始交易时会切换为单向)" : "单向持仓"
ElMessage.success({ message: `连接成功,用时 ${r.elapsed_ms}ms;可用 ${r.available_usdt} USDT,钱包余额 ${r.wallet_usdt} USDT;${mode}`, duration: 8000 })
} else {
ElMessage.error({ message: `连接失败(${r.elapsed_ms}ms):${r.error}`, duration: 8000 })
}
} finally {
exchangeTesting.value = false
}
}
onMounted(loadExchange)
</script>
<template>
<section class="min-w-0">
<el-alert v-if="failed" type="error" :closable="false" title="交易所配置读取失败">
<el-button link @click="loadExchange">
重试
</el-button>
</el-alert>
<el-card v-loading="loading" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div>
模拟盘交易所
<el-select :model-value="exchange?.exchange" class="ml-3 w-36" :disabled="!exchange || exchange.running || exchangeSaving || exchangeTesting" @update:model-value="changeExchange">
<el-option value="binance" label="币安模拟盘" />
<el-option value="bybit" label="Bybit 模拟盘" />
</el-select>
<div class="mt-2 text-xs text-slate-500 font-normal">
{{ exchange?.exchange === "bybit" ? "在 Bybit 主站切换到模拟交易后创建 API Key,并开启合约交易权限;实盘和 Testnet 的 Key 不可用。" : "在 demo.binance.com 的 API 管理创建 Key,并开启合约交易权限。" }}
两家凭证分别保存;切换不会撤销原交易所的挂单,旧挂单仍须在原平台处理。
</div>
</div>
<div>
<el-button :disabled="!exchange?.configured" :loading="exchangeTesting" @click="runExchangeTest">
测试连接
</el-button>
<el-button type="primary" :disabled="!exchange || exchange.running || exchangeSaving || exchangeTesting" @click="openExchangeEdit">
{{ exchange?.configured ? "修改 key" : "填写 key" }}
</el-button>
<el-button v-if="exchange?.configured" type="danger" plain :disabled="exchange.running" @click="removeExchange">
删除
</el-button>
</div>
</div>
</template>
<el-descriptions v-if="exchange" :column="3" border>
<el-descriptions-item label="API Key">
{{ exchange.configured ? `****${exchange.api_key_hint}` : "未配置" }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="exchange.running" type="success" size="small">
有币种在交易(全部停止后才能修改 key)
</el-tag>
<span v-else>未运行</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ exchange.updated_at ? formatTime(exchange.updated_at, "MM-DD HH:mm") : "-" }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-dialog v-model="exchangeDialogVisible" :title="`${exchange?.exchange === 'bybit' ? 'Bybit' : '币安'}模拟盘 API Key`" width="min(560px, 94vw)" :close-on-click-modal="!exchangeSaving" :close-on-press-escape="!exchangeSaving" :show-close="!exchangeSaving">
<el-form ref="exchangeFormRef" :model="exchangeForm" :rules="rules" :disabled="exchangeSaving" label-width="100px">
<el-form-item prop="api_key" label="API Key" required>
<el-input v-model="exchangeForm.api_key" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item prop="api_secret" label="Secret Key" required>
<el-input v-model="exchangeForm.api_secret" type="password" show-password autocomplete="new-password" />
<div class="text-xs text-[var(--el-text-color-secondary)]">
两项都要填;加密保存,之后不再显示
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="exchangeSaving" @click="exchangeDialogVisible = false">
取消
</el-button>
<el-button type="primary" :loading="exchangeSaving" :disabled="!exchangeForm.api_key.trim() || !exchangeForm.api_secret.trim()" @click="saveExchange">
保存
</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import type { Provider, ProviderForm } from "@@/apis/settings/type"
import type { FormInstance, FormRules } from "element-plus"
import { createProviderApi, deleteProviderApi, listProvidersApi, patchProviderApi, testProviderApi } from "@@/apis/settings"
import { useSessionScope } from "@@/composables/useSessionScope"
import { EFFORTS } from "@@/constants/ai"
import { formatTime } from "@@/utils/text"
import { Plus } from "@element-plus/icons-vue"
const sessionScope = useSessionScope()
const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", anthropic: "Claude 格式(/v1/messages)" } as const
const providers = ref<Provider[]>([])
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const testing = ref<number | null>(null)
function emptyForm(): ProviderForm {
return { name: "", protocol: "anthropic", base_url: "", api_key: "", model: "", max_tokens: 8192, context_window: 200000, reasoning_effort: null, is_default: false }
}
const form = ref<ProviderForm>(emptyForm())
const formRef = ref<FormInstance>()
const rules: FormRules = {
name: [{ required: true, whitespace: true, message: "请输入名称", trigger: "blur" }],
base_url: [{ validator: (_rule, value, callback) => {
const message = "请输入 HTTP 或 HTTPS 地址"
let url: URL
try {
url = new URL(value)
} catch {
callback(new Error(message))
return
}
callback(["http:", "https:"].includes(url.protocol) ? undefined : new Error(message))
}, trigger: "blur" }],
api_key: [{ validator: (_rule, value, callback) => callback(editingId.value === null && !value?.trim() ? new Error("请输入 API Key") : undefined), trigger: "blur" }],
model: [{ required: true, whitespace: true, message: "请输入模型名称", trigger: "blur" }],
max_tokens: [{ required: true, type: "integer", min: 256, max: 128000, message: "最大输出须为 256–128000 的整数", trigger: "blur" }],
context_window: [{ required: true, type: "integer", min: 4096, max: 2000000, message: "上下文上限须为 4096–2000000 的整数", trigger: "blur" }]
}
const loading = ref(false)
const failed = ref(false)
async function load() {
loading.value = true
try {
providers.value = await listProvidersApi()
failed.value = false
} catch (error) {
failed.value = true
throw error
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
form.value = emptyForm()
formRef.value?.clearValidate()
dialogVisible.value = true
}
function openEdit(p: Provider) {
editingId.value = p.id
// key 不回显,留空表示不修改
form.value = {
name: p.name,
protocol: p.protocol,
base_url: p.base_url,
api_key: "",
model: p.model,
max_tokens: p.max_tokens,
context_window: p.context_window,
reasoning_effort: p.reasoning_effort,
is_default: p.is_default
}
formRef.value?.clearValidate()
dialogVisible.value = true
}
async function save() {
if (saving.value) return
saving.value = true
try {
await formRef.value!.validate()
if (editingId.value === null) {
await createProviderApi(form.value)
} else {
const { api_key, ...rest } = form.value
await patchProviderApi(editingId.value, api_key.trim() ? form.value : rest)
}
ElMessage.success("已保存")
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function setDefault(p: Provider) {
await patchProviderApi(p.id, { is_default: true })
await load()
}
async function remove(p: Provider) {
await ElMessageBox.confirm(`删除模型配置「${p.name}」?`, "删除确认", { type: "warning" })
sessionScope.assertCurrent()
await deleteProviderApi(p.id)
ElMessage.success("已删除")
await load()
}
async function test(p: Provider) {
testing.value = p.id
try {
const result = await testProviderApi(p.id)
if (result.ok)
ElMessage.success(`连接成功,用时 ${result.elapsed_ms}ms,回复:${result.reply}`)
else
ElMessage.error({ message: `连接失败(${result.elapsed_ms}ms):${result.error}`, duration: 8000 })
} finally {
testing.value = null
}
}
onMounted(load)
</script>
<template>
<section class="min-w-0">
<el-card shadow="never" body-class="p-0">
<template #header>
<div class="flex items-center justify-between">
<div>
模型配置
<span class="ml-3 text-xs text-slate-500 font-normal">通过 Sub2API 等中转接入;API key 加密存储,页面只显示末 4 位</span>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">
新增配置
</el-button>
</div>
</template>
<el-alert v-if="failed" type="error" :closable="false" title="模型配置读取失败">
<el-button link @click="load">
重试
</el-button>
</el-alert>
<el-table v-loading="loading" :data="providers" :empty-text="failed ? '读取失败' : '暂无模型配置'">
<el-table-column label="名称" min-width="120">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="row.is_default" size="small" type="success" class="ml-1">
默认
</el-tag>
</template>
</el-table-column>
<el-table-column label="协议" min-width="200">
<template #default="{ row }">
{{ PROTOCOL_LABELS[row.protocol as keyof typeof PROTOCOL_LABELS] }}
</template>
</el-table-column>
<el-table-column prop="base_url" label="Base URL" min-width="200" />
<el-table-column prop="model" label="模型" min-width="150" />
<el-table-column label="思考强度" width="90">
<template #default="{ row }">
{{ (row as Provider).reasoning_effort ?? "-" }}
</template>
</el-table-column>
<el-table-column label="API Key" width="100">
<template #default="{ row }">
****{{ row.api_key_hint }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="130">
<template #default="{ row }">
{{ formatTime(row.updated_at, "MM-DD HH:mm") }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="testing !== null || saving" :loading="testing === row.id" @click="test(row as Provider)">
测试连接
</el-button>
<el-button link @click="openEdit(row as Provider)">
编辑
</el-button>
<el-button v-if="!row.is_default" link @click="setDefault(row as Provider)">
设为默认
</el-button>
<el-button link type="danger" @click="remove(row as Provider)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId === null ? '新增模型配置' : '编辑模型配置'" width="min(560px, 94vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving">
<el-form ref="formRef" :model="form" :rules="rules" :disabled="saving" label-width="100px">
<el-form-item prop="name" label="名称" required>
<el-input v-model="form.name" placeholder="如 Sub2API-Claude" />
</el-form-item>
<el-form-item label="协议" required>
<el-radio-group v-model="form.protocol">
<el-radio value="anthropic">
Claude 格式
</el-radio>
<el-radio value="openai">
OpenAI 格式
</el-radio>
</el-radio-group>
<div class="text-xs text-[var(--el-text-color-secondary)]">
选 key 所在分组的原生协议;Sub2API 的协议转换会丢失部分信息
</div>
</el-form-item>
<el-form-item prop="base_url" label="Base URL" required>
<el-input v-model="form.base_url" placeholder="https://your-sub2api.example.com(带不带 /v1 都可以)" />
</el-form-item>
<el-form-item prop="api_key" label="API Key" :required="editingId === null">
<el-input v-model="form.api_key" type="password" show-password autocomplete="new-password" :placeholder="editingId === null ? '' : '留空表示不修改'" />
</el-form-item>
<el-form-item prop="model" label="模型" required>
<el-input v-model="form.model" placeholder="如 claude-sonnet-4-5" />
</el-form-item>
<el-form-item prop="max_tokens" label="最大输出">
<el-input-number v-model="form.max_tokens" :min="256" :max="128000" :step="1024" controls-position="right" />
<span class="ml-2 text-xs">token</span>
</el-form-item>
<el-form-item prop="context_window" label="上下文上限">
<el-input-number v-model="form.context_window" :min="4096" :max="2000000" :step="10000" controls-position="right" />
<span class="ml-2 text-xs">token,超过时拒绝发送</span>
</el-form-item>
<el-form-item label="思考强度">
<el-select v-model="form.reasoning_effort" clearable placeholder="不设置" class="w-40" :value-on-clear="null">
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="e" />
</el-select>
<span class="ml-2 text-xs">Chat 默认值,可在 Chat 页按轮调整;留空则不传,由模型决定。越高越慢、越贵</span>
</el-form-item>
<el-form-item label="设为默认">
<el-switch v-model="form.is_default" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="saving" @click="dialogVisible = false">
取消
</el-button>
<el-button type="primary" :loading="saving" @click="save">
保存
</el-button>
</template>
</el-dialog>
</section>
</template>