feat: add resume agent MVP

This commit is contained in:
OfferPai
2026-07-20 14:48:41 +08:00
commit 48599bf55b
65 changed files with 10988 additions and 0 deletions
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import {
booleanValue,
initialValue,
optionList,
stringValue,
} from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
inputOnly?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false, inputOnly: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const accountOptions = computed(() => {
if (props.inputOnly) return []
const explicit = optionList(props.data.options ?? props.data.phones ?? props.data.choices)
if (explicit.length) return explicit
return [
...(booleanValue(props.data.has_account_phone)
? [{
value: 'account',
label: stringValue(props.data.masked_phone, '使用登录手机号'),
description: '将登录手机号作为简历联系电话',
}]
: []),
{ value: 'manual', label: '输入其他手机号', description: '仅校验格式,不发送验证码' },
]
})
const sourceValue = computed(() =>
typeof props.value === 'string' ? props.value : initialValue(props.data),
)
const selected = ref(sourceValue.value)
const manualPhone = ref(props.inputOnly ? sourceValue.value : '')
const validationError = ref('')
watch(sourceValue, (next) => {
selected.value = next
if (props.inputOnly) manualPhone.value = next
})
const resolvedValue = computed(() => (props.inputOnly ? manualPhone.value.trim() : selected.value))
const resolvedLabel = computed(
() => accountOptions.value.find((option) => option.value === sourceValue.value)?.label || sourceValue.value,
)
function selectPhone(value: string) {
selected.value = value
validationError.value = ''
}
function submit() {
if (!props.inputOnly) {
if (!selected.value) {
validationError.value = '请选择手机号来源。'
return
}
const source = ['manual', 'other'].includes(selected.value) ? 'manual' : 'account'
emit('submit', {
event: 'select',
payload: { value: source, source },
})
return
}
const phone = resolvedValue.value.trim()
if (!/^1[3-9]\d{9}$/.test(phone)) {
validationError.value = '手机号格式不正确,请检查后重试。'
return
}
emit('submit', {
event: 'submit',
payload: { value: phone, phone },
})
}
</script>
<template>
<FormCard
eyebrow="简历定位"
:title="stringValue(data.title, inputOnly ? '输入其他简历手机号' : '选择简历联系电话')"
:description="stringValue(data.description, '可以使用登录手机号,也可以填写其他中国大陆手机号。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ resolvedLabel || '手机号已记录' }}</div>
<form v-else novalidate @submit.prevent="submit">
<ul v-if="!inputOnly" class="phone-list">
<li v-for="phone in accountOptions" :key="phone.value">
<button
class="phone-option"
:class="{ 'phone-option--selected': selected === phone.value }"
type="button"
:disabled="pending || phone.disabled"
:aria-pressed="selected === phone.value"
@click="selectPhone(phone.value)"
>
<span class="phone-option__avatar" aria-hidden="true">{{ phone.label.slice(-2) }}</span>
<span>
<strong>{{ phone.label }}</strong>
<small>{{ phone.description || '已有简历记录' }}</small>
</span>
</button>
</li>
</ul>
<div v-if="inputOnly" class="field-group">
<label class="field-label" for="resume-phone">手机号</label>
<input
id="resume-phone"
v-model="manualPhone"
class="text-input"
type="tel"
inputmode="tel"
autocomplete="tel"
:placeholder="stringValue(data.placeholder, '例如:13800138000')"
:disabled="pending"
:aria-invalid="Boolean(validationError)"
aria-describedby="resume-phone-help resume-phone-error"
@input="validationError = ''"
/>
<p id="resume-phone-help" class="field-hint">仅校验 11 位中国大陆手机号格式不发送验证码</p>
<p v-if="validationError" id="resume-phone-error" class="validation-error" role="alert">
{{ validationError }}
</p>
</div>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="!resolvedValue || pending">
继续
</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.phone-list {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
list-style: none;
}
.phone-option {
display: flex;
width: 100%;
min-height: 70px;
align-items: center;
gap: 12px;
padding: 11px 13px;
border: 1px solid var(--line);
border-radius: 16px;
color: var(--ink);
background: #f9fcfb;
text-align: left;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.phone-option:hover:not(:disabled) {
transform: translateY(-1px);
border-color: #91cbc7;
}
.phone-option--selected {
border-color: var(--brand);
background: var(--brand-soft);
}
.phone-option__avatar {
display: grid;
width: 42px;
height: 42px;
flex: none;
place-items: center;
border-radius: 13px;
color: var(--brand-dark);
background: #fff;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 11px;
font-weight: 800;
}
.phone-option strong,
.phone-option small {
display: block;
}
.phone-option strong {
font-size: 14px;
}
.phone-option small {
margin-top: 3px;
color: var(--ink-faint);
font-size: 11px;
}
.validation-error {
margin: 0;
color: var(--danger);
font-size: 12px;
}
</style>