Fix workspace detail races and provider default handling
Keep analysis and Skill detail responses bound to the selected request, reject stale publish confirmations, and clear failed position-history results with retry controls. Reject removal of the current default provider with HTTP 409 and reflect that contract in the form. Track the OpenAPI page and API module by restricting the docs ignore rule to the repository root. Validate the staged provider contract with four isolated tests. Reuse the matching candidate's frontend lint, type check, 26 test groups, and production build results.
This commit is contained in:
1 parent
3178a2cc31
commit
d1a6325ed7
12 files changed
+405
-23
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import type * as Docs from "./type"
|
||||
import { request } from "@/http/axios"
|
||||
|
||||
/** OpenAPI 按原始规范返回,不使用普通 JSON 外壳。 */
|
||||
export function getOpenApiApi(signal?: AbortSignal) {
|
||||
return request<Docs.OpenApiDocument>({
|
||||
url: "/openapi.json",
|
||||
method: "get",
|
||||
rawResponse: true,
|
||||
notify: false,
|
||||
signal
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface OpenApiDocument {
|
||||
openapi: string
|
||||
info: { title: string, version: string }
|
||||
paths: Record<string, unknown>
|
||||
components?: Record<string, unknown>
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export interface ProviderForm {
|
||||
context_window: number
|
||||
/** 为空表示不传,由模型决定 */
|
||||
reasoning_effort: Effort | null
|
||||
/** 修改时不能直接取消当前默认模型;通过将其他模型设为默认切换。 */
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SkillNode, SkillVersion } from "@@/apis/skills/type"
|
||||
import { listSkillVersionsApi, publishSkillVersionApi } from "@@/apis/skills"
|
||||
import { useSessionScope } from "@@/composables/useSessionScope"
|
||||
import { diffLines, formatTime } from "@@/utils/text"
|
||||
import { CanceledError } from "axios"
|
||||
|
||||
const props = defineProps<{ skill: SkillNode | null }>()
|
||||
|
||||
@@ -16,32 +17,53 @@ const visible = defineModel<boolean>({ required: true })
|
||||
const versions = ref<SkillVersion[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const publishing = ref(false)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
let requestSeq = 0
|
||||
|
||||
const published = computed(() => versions.value.find(v => v.version_no === props.skill?.published_version_no) ?? null)
|
||||
const selected = computed(() => versions.value.find(v => v.id === selectedId.value) ?? null)
|
||||
const diff = computed(() => selected.value ? diffLines((published.value?.content ?? "").split("\n"), selected.value.content.split("\n")) : [])
|
||||
|
||||
async function load() {
|
||||
if (!props.skill)
|
||||
return
|
||||
versions.value = await listSkillVersionsApi(props.skill.id)
|
||||
// 默认选中最新版本,直接看到草稿相对已发布版本的改动
|
||||
selectedId.value = versions.value[0]?.id ?? null
|
||||
const seq = ++requestSeq
|
||||
const id = props.skill?.id
|
||||
versions.value = []
|
||||
selectedId.value = null
|
||||
failed.value = false
|
||||
loading.value = false
|
||||
if (!visible.value || id === undefined) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listSkillVersionsApi(id)
|
||||
if (seq !== requestSeq || !visible.value || id !== props.skill?.id) return
|
||||
versions.value = result
|
||||
// 默认选中最新版本,直接看到草稿相对已发布版本的改动
|
||||
selectedId.value = result[0]?.id ?? null
|
||||
} catch (error) {
|
||||
if (seq === requestSeq) failed.value = true
|
||||
throw error
|
||||
} finally {
|
||||
if (seq === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], ([open]) => {
|
||||
if (open)
|
||||
load()
|
||||
})
|
||||
watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], load)
|
||||
onBeforeUnmount(() => ++requestSeq)
|
||||
|
||||
async function publish(version: SkillVersion) {
|
||||
const skillId = props.skill!.id
|
||||
const seq = requestSeq
|
||||
const current = props.skill?.published_version_no
|
||||
const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布"
|
||||
await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
|
||||
sessionScope.assertCurrent()
|
||||
if (seq !== requestSeq || !visible.value || skillId !== props.skill?.id)
|
||||
throw new CanceledError("查看的 Skill 已切换")
|
||||
publishing.value = true
|
||||
try {
|
||||
await publishSkillVersionApi(props.skill!.id, version.id)
|
||||
await publishSkillVersionApi(skillId, version.id)
|
||||
if (seq !== requestSeq) return
|
||||
ElMessage.success(`已${action} v${version.version_no}`)
|
||||
emit("published")
|
||||
} finally {
|
||||
@@ -52,7 +74,12 @@ async function publish(version: SkillVersion) {
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="`版本历史:${skill?.path ?? ''}`" size="60%">
|
||||
<div class="h-full flex gap-3">
|
||||
<el-empty v-if="failed" description="版本历史读取失败">
|
||||
<el-button type="primary" @click="load">
|
||||
重试
|
||||
</el-button>
|
||||
</el-empty>
|
||||
<div v-else v-loading="loading" class="h-full flex gap-3">
|
||||
<el-scrollbar class="w-60 shrink-0">
|
||||
<div
|
||||
v-for="v in versions" :key="v.id"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { getOpenApiApi } from "@@/apis/docs"
|
||||
import { rejectAuthentication } from "@/http/axios"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
|
||||
const target = ref<HTMLElement>()
|
||||
const error = ref("")
|
||||
const user = useUserStore()
|
||||
const version = user.sessionVersion
|
||||
const controller = new AbortController()
|
||||
let disposed = false
|
||||
|
||||
interface SwaggerWindow extends Window {
|
||||
SwaggerUIBundle?: (options: Record<string, unknown>) => unknown
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const spec = await getOpenApiApi(controller.signal)
|
||||
const source = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"
|
||||
if (!(window as SwaggerWindow).SwaggerUIBundle) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement("script")
|
||||
script.src = source
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error("接口文档资源加载失败"))
|
||||
document.head.append(script)
|
||||
})
|
||||
}
|
||||
if (version !== user.sessionVersion || disposed) {
|
||||
return
|
||||
}
|
||||
const render = (window as SwaggerWindow).SwaggerUIBundle!
|
||||
render({
|
||||
spec,
|
||||
domNode: target.value,
|
||||
requestInterceptor: (request: { headers: Record<string, string> }) => {
|
||||
if (version !== user.sessionVersion) throw new Error("会话已切换")
|
||||
request.headers.Authorization = `Bearer ${user.token}`
|
||||
return request
|
||||
},
|
||||
responseInterceptor: (response: { status: number }) => {
|
||||
if (response.status === 401) rejectAuthentication(version)
|
||||
return response
|
||||
}
|
||||
})
|
||||
} catch (reason) {
|
||||
if (!disposed && version === user.sessionVersion)
|
||||
error.value = (reason as Error).message
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
controller.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto bg-white p-4">
|
||||
<p v-if="error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<div ref="target" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import url("https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css");
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", ant
|
||||
const providers = ref<Provider[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingDefault = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref<number | null>(null)
|
||||
|
||||
@@ -58,6 +59,7 @@ async function load() {
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
editingDefault.value = false
|
||||
form.value = emptyForm()
|
||||
formRef.value?.clearValidate()
|
||||
dialogVisible.value = true
|
||||
@@ -65,6 +67,7 @@ function openCreate() {
|
||||
|
||||
function openEdit(p: Provider) {
|
||||
editingId.value = p.id
|
||||
editingDefault.value = p.is_default
|
||||
// key 不回显,留空表示不修改
|
||||
form.value = {
|
||||
name: p.name,
|
||||
@@ -240,7 +243,8 @@ onMounted(load)
|
||||
<span class="ml-2 text-xs">Chat 默认值,可在 Chat 页按轮调整;留空则不传,由模型决定。越高越慢、越贵</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="设为默认">
|
||||
<el-switch v-model="form.is_default" />
|
||||
<el-switch v-model="form.is_default" :disabled="editingDefault" />
|
||||
<span v-if="editingDefault" class="ml-2 text-xs">请将其他模型设为默认以切换</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
|
||||
@@ -8,12 +8,32 @@ const props = defineProps<{ analysisId: number | null }>()
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
|
||||
const detail = ref<AnalysisDetail>()
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
let requestSeq = 0
|
||||
|
||||
watch(() => [visible.value, props.analysisId] as const, async ([open, id]) => {
|
||||
async function load() {
|
||||
const seq = ++requestSeq
|
||||
const id = props.analysisId
|
||||
detail.value = undefined
|
||||
if (open && id)
|
||||
detail.value = await getAnalysisApi(id)
|
||||
})
|
||||
failed.value = false
|
||||
loading.value = false
|
||||
if (!visible.value || id === null) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getAnalysisApi(id)
|
||||
if (seq === requestSeq && visible.value && id === props.analysisId)
|
||||
detail.value = result
|
||||
} catch (error) {
|
||||
if (seq === requestSeq) failed.value = true
|
||||
throw error
|
||||
} finally {
|
||||
if (seq === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [visible.value, props.analysisId] as const, load)
|
||||
onBeforeUnmount(() => ++requestSeq)
|
||||
|
||||
const toolResults = computed(() => {
|
||||
const results = new Map<string, string>()
|
||||
@@ -165,7 +185,12 @@ function promptMarkdown(text: string) {
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</div>
|
||||
<div v-else v-loading="true" class="h-40" />
|
||||
<el-empty v-else-if="failed" description="分析详情读取失败">
|
||||
<el-button type="primary" @click="load">
|
||||
重试
|
||||
</el-button>
|
||||
</el-empty>
|
||||
<div v-else v-loading="loading" class="h-40" />
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -25,12 +25,15 @@ const page = ref(1)
|
||||
const result = ref<PositionHistoryPage>()
|
||||
const symbols = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
// 快速切换筛选时只采用最后一次请求的结果
|
||||
let requestSeq = 0
|
||||
|
||||
async function load() {
|
||||
const seq = ++requestSeq
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
result.value = undefined
|
||||
try {
|
||||
const data = await listPositionHistoryApi({
|
||||
symbol: filters.symbol,
|
||||
@@ -44,6 +47,9 @@ async function load() {
|
||||
})
|
||||
if (seq === requestSeq)
|
||||
result.value = data
|
||||
} catch (error) {
|
||||
if (seq === requestSeq) failed.value = true
|
||||
throw error
|
||||
} finally {
|
||||
if (seq === requestSeq)
|
||||
loading.value = false
|
||||
@@ -52,15 +58,18 @@ async function load() {
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
return load()
|
||||
}
|
||||
|
||||
watch(visible, async (open) => {
|
||||
if (!open)
|
||||
if (!open) {
|
||||
++requestSeq
|
||||
loading.value = false
|
||||
return
|
||||
search()
|
||||
symbols.value = await listPositionHistorySymbolsApi()
|
||||
}
|
||||
await Promise.all([search(), listPositionHistorySymbolsApi().then(list => symbols.value = list)])
|
||||
})
|
||||
onBeforeUnmount(() => ++requestSeq)
|
||||
|
||||
const winRate = computed(() => {
|
||||
const s = result.value?.summary
|
||||
@@ -121,6 +130,12 @@ function pnlClass(value: string) {
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="failed" type="error" :closable="false" title="历史仓位查询失败">
|
||||
<el-button link @click="load">
|
||||
重试
|
||||
</el-button>
|
||||
</el-alert>
|
||||
|
||||
<div v-if="result" class="flex flex-wrap gap-x-8 gap-y-1 border border-slate-200 rounded-lg border-solid bg-slate-50 px-4 py-3 text-xs text-slate-500">
|
||||
<span>笔数 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.count }}</b></span>
|
||||
<span>盈利 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.wins }}</b></span>
|
||||
@@ -129,7 +144,7 @@ function pnlClass(value: string) {
|
||||
<span>手续费 <b class="num ml-1 text-sm text-slate-900">{{ Number(result.summary.commission).toFixed(2) }} USDT</b></span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="result?.items ?? []" cell-class-name="num" empty-text="没有符合条件的仓位">
|
||||
<el-table v-loading="loading" :data="result?.items ?? []" cell-class-name="num" :empty-text="failed ? '查询失败' : '没有符合条件的仓位'">
|
||||
<el-table-column label="币种" min-width="84">
|
||||
<template #default="{ row }">
|
||||
<span class="text-slate-900 font-semibold">{{ row.symbol }}</span>
|
||||
|
||||
Reference in new issue
Block a user