接上 vue-tsc 时有 143 条既有错误,上一批迁移带走 89 条,这里把剩下的清完。
大部分是噪音(未使用的回调参数、死变量、少数 unknown),但里面躺着两个真问题:
- 个人主页查不存在的用户会抛:getProfile 返回 null 时后面照样取
.acmProblemsStatus,靠 `!` 压着。加了早返回。
- getProblemSetProgress 我上一批标错了类型:后台这个接口返回的是裸数组,
不是分页信封(和 oj 侧的 /user-progress 不一样)。已改正并加注释区分。
其余处理:
- 未使用的回调参数按 TS 约定加 `_` 前缀(v-for 的项、供子类重写的空钩子、
路由守卫的 from);确认无用的局部变量直接删(clickX/clickY 算了点击位置
但飞出方向用的是随机角度,hasToday 下面已经用 lastDateOnly 判过,
prefix 算了周/月但标签里没用上)。
- App.vue 的 highlight.js 注册:Promise.all([...]).then(m => m.map(x => x.default))
会把元组塌成 (HLJSApi | LanguageFn)[],hljs 上就找不到 registerLanguage。
改成逐个取 .default。
- 给一批 api 补上契约类型(getMetrics / getHitokoto / getTutorials /
formatCode / getProblemBeatRate / getClassUsernames 等),契约相应补了
8 个 z.infer 导出。
- ContestRank.submissionInfo 和 AcmHelperItem.acInfo 在 api 边界窄化成
SubmissionInfo —— 契约里是 Record<string, unknown>(JSONB 原文)。
- FlowchartEditor 的 TS2589:vue-flow 的 Node 嵌套太深,ref<Node[]>([]) 的
UnwrapRef 推导撞上实例化层级上限,改成 ref([]) as Ref<Node[]>。
- api2.ts 的响应拦截器**故意**不返回 AxiosResponse(要把 { data } 信封剥掉),
类型上确实说不通,没硬掰,写注释说明为什么用断言。
- 题目列表的「随机」按钮在模板里一直是注释状态,对应的 getRandom 和
getRandomProblemID 一并删除(后端 /problems/random 保留不动)。
验证:vue-tsc 0 条,apps/api tsc、check:routes、web build 全通过;
修过 key 的列都打接口确认过字段真实存在。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
5.9 KiB
Vue
276 lines
5.9 KiB
Vue
<template>
|
|
<div
|
|
class="custom-node"
|
|
:class="{ 'is-hovered': isHovered, 'is-editing': isEditing }"
|
|
:data-node-type="nodeType"
|
|
:draggable="!isEditing"
|
|
@mouseenter="handleMouseEnter"
|
|
@mouseleave="handleMouseLeave"
|
|
@dblclick="handleDoubleClick"
|
|
@dragstart="handleDragStart"
|
|
@mousedown="handleMouseDown"
|
|
>
|
|
<!-- 连线点 - 根据节点类型动态显示 -->
|
|
<NodeHandles :node-type="nodeType" :node-config="nodeConfig" />
|
|
|
|
<!-- 节点内容 -->
|
|
<div class="node-content">
|
|
<!-- 显示模式 -->
|
|
<span v-if="!isEditing" class="node-label">{{ displayLabel }}</span>
|
|
|
|
<!-- 编辑模式 -->
|
|
<input
|
|
v-if="isEditing"
|
|
ref="editInput"
|
|
v-model="editText"
|
|
class="node-input"
|
|
@blur="handleSaveEdit"
|
|
@keydown.enter="handleSaveEdit"
|
|
@keydown.escape="handleCancelEdit"
|
|
@click.stop
|
|
/>
|
|
|
|
<!-- 隐藏的文字用于保持尺寸 -->
|
|
<span v-if="isEditing" class="node-label-hidden" aria-hidden="true">
|
|
{{ displayLabel }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 悬停时显示的操作按钮 -->
|
|
<NodeActions
|
|
v-if="isHovered"
|
|
@delete="handleDelete"
|
|
@mouseenter="handleMouseEnter"
|
|
@mouseleave="handleMouseLeave"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { ref, onUnmounted, nextTick, computed } from "vue"
|
|
import { getNodeTypeConfig } from "./useNodeStyles"
|
|
import NodeHandles from "./NodeHandles.vue"
|
|
import NodeActions from "./NodeActions.vue"
|
|
|
|
interface NodeData {
|
|
label: string
|
|
color: string
|
|
originalType: string
|
|
customLabel?: string
|
|
}
|
|
|
|
interface Props {
|
|
id: string
|
|
type: string
|
|
data: NodeData
|
|
}
|
|
|
|
interface Emits {
|
|
delete: [nodeId: string]
|
|
update: [nodeId: string, newLabel: string]
|
|
}
|
|
|
|
// Props 和 Emits
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<Emits>()
|
|
|
|
// 响应式状态
|
|
const isHovered = ref(false)
|
|
const isEditing = ref(false)
|
|
const editText = ref("")
|
|
const editInput = useTemplateRef<HTMLInputElement>("editInput")
|
|
|
|
// 定时器和事件处理器
|
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
|
let globalClickHandler: ((event: MouseEvent) => void) | null = null
|
|
|
|
// 计算属性
|
|
const nodeType = computed(() => props.data.originalType || props.type)
|
|
const nodeConfig = computed(() => getNodeTypeConfig(nodeType.value))
|
|
const displayLabel = computed(
|
|
() => props.data.customLabel || nodeConfig.value.label,
|
|
)
|
|
|
|
// 事件处理器
|
|
const handleDelete = () => emit("delete", props.id)
|
|
|
|
const handleMouseDown = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement
|
|
if (target.closest(".vue-flow__handle")) {
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
const handleDragStart = (event: DragEvent) => {
|
|
if (isEditing.value) return
|
|
|
|
const target = event.target as HTMLElement
|
|
if (target.closest(".vue-flow__handle")) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.effectAllowed = "move"
|
|
}
|
|
}
|
|
|
|
const handleDoubleClick = (event: MouseEvent) => {
|
|
event.stopPropagation()
|
|
if (!isEditing.value) {
|
|
isEditing.value = true
|
|
editText.value = displayLabel.value
|
|
nextTick(() => {
|
|
editInput.value?.focus()
|
|
editInput.value?.select()
|
|
})
|
|
addGlobalClickHandler()
|
|
}
|
|
}
|
|
|
|
const handleSaveEdit = () => {
|
|
if (isEditing.value) {
|
|
emit("update", props.id, editText.value.trim())
|
|
isEditing.value = false
|
|
removeGlobalClickHandler()
|
|
}
|
|
}
|
|
|
|
const handleCancelEdit = () => {
|
|
isEditing.value = false
|
|
editText.value = ""
|
|
removeGlobalClickHandler()
|
|
}
|
|
|
|
const handleMouseEnter = () => {
|
|
isHovered.value = true
|
|
if (hideTimeout) {
|
|
clearTimeout(hideTimeout)
|
|
hideTimeout = null
|
|
}
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
if (hideTimeout) {
|
|
clearTimeout(hideTimeout)
|
|
}
|
|
hideTimeout = setTimeout(() => {
|
|
isHovered.value = false
|
|
}, 300)
|
|
}
|
|
|
|
// 全局点击处理器
|
|
const addGlobalClickHandler = () => {
|
|
if (globalClickHandler) return
|
|
|
|
globalClickHandler = (event: MouseEvent) => {
|
|
if (
|
|
isEditing.value &&
|
|
!(event.target as Element)?.closest(".custom-node")
|
|
) {
|
|
handleSaveEdit()
|
|
}
|
|
}
|
|
document.addEventListener("click", globalClickHandler, { capture: true })
|
|
}
|
|
|
|
const removeGlobalClickHandler = () => {
|
|
if (globalClickHandler) {
|
|
document.removeEventListener("click", globalClickHandler, { capture: true })
|
|
globalClickHandler = null
|
|
}
|
|
}
|
|
|
|
// 清理函数
|
|
onUnmounted(() => {
|
|
if (hideTimeout) {
|
|
clearTimeout(hideTimeout)
|
|
}
|
|
removeGlobalClickHandler()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 主容器 */
|
|
.custom-node {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: inherit;
|
|
transition: all 0.2s ease;
|
|
padding: 0 20px;
|
|
}
|
|
|
|
.custom-node.is-hovered {
|
|
z-index: 1;
|
|
}
|
|
|
|
.custom-node.is-hovered .node-content {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
/* 节点内容区域 */
|
|
.node-content {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: inherit;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
/* 节点标签 */
|
|
.node-label {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
display: inline-block;
|
|
text-align: center;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
/* 编辑输入框 */
|
|
.node-input {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: transparent;
|
|
border: none;
|
|
outline: none;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: inherit;
|
|
text-align: center;
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 0;
|
|
margin: 0;
|
|
box-sizing: border-box;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
resize: none;
|
|
font-family: inherit;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
/* 隐藏标签(用于保持尺寸) */
|
|
.node-label-hidden {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
display: inline-block;
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
text-align: center;
|
|
line-height: 1.2;
|
|
}
|
|
</style>
|