接入M002V4订单任务多卡前端

This commit is contained in:
andy committed 2026-07-19 13:48:07 +07:00
1 parent 13364e7edf
commit cae169cefd
19 files changed
+4084 -343

No files matched your search

@@ -18,16 +18,16 @@ const props = defineProps<{
const { t } = useI18n()
const tone = computed(() => {
if (['ACTIVE', 'COMPLETED', 'SUCCEEDED'].includes(props.status)) {
if (['ACTIVE', 'COMPLETED', 'SUCCEEDED', 'CONFIRMED', 'ACKED'].includes(props.status)) {
return 'success'
}
if (['PENDING_CONFIRM', 'READY', 'PENDING', 'TEMPORARY', 'EXECUTING'].includes(props.status)) {
if (['PENDING_CONFIRM', 'READY', 'PENDING', 'TEMPORARY', 'EXECUTING', 'OPEN', 'ACK_REQUIRED'].includes(props.status)) {
return 'info'
}
if (['BLOCKED', 'FAILED'].includes(props.status)) {
return 'danger'
}
if (['ENDED', 'LOGIC_DELETED', 'SKIPPED'].includes(props.status)) {
if (['ENDED', 'LOGIC_DELETED', 'SKIPPED', 'READONLY'].includes(props.status)) {
return 'neutral'
}
return 'warning'
@@ -0,0 +1,339 @@
<template>
<section class="v4-source-card th-section">
<div class="th-section-header">
<div>
<h2 class="th-section-title">
{{ title }}
</h2>
<p
v-if="sourceMessageId"
class="th-muted"
>
{{ t('task.sourceMessageId') }}: {{ sourceMessageId }}
</p>
</div>
<ReservationStatusBadge
v-if="sourceCard"
:status="sourceCard.card_status"
/>
</div>
<div class="source-grid">
<div>
<span>{{ t('taskV4.sourceMessage.subject') }}</span>
<strong>{{ sourceSubject }}</strong>
</div>
<div>
<span>{{ t('taskV4.sourceMessage.sender') }}</span>
<strong>{{ sourceSender }}</strong>
</div>
<div>
<span>{{ t('taskV4.sourceMessage.receivedAt') }}</span>
<strong>{{ formatReservationDateTime(sourceReceivedAt) }}</strong>
</div>
<div>
<span>{{ t('taskV4.sourceMessage.conversationCount') }}</span>
<strong>{{ conversationMessageCount }}</strong>
</div>
</div>
<div class="source-preview">
<h3>{{ t('taskV4.sourceMessage.excerpt') }}</h3>
<p>{{ sourceExcerpt }}</p>
</div>
<div class="source-preview">
<h3>{{ t('taskV4.sourceMessage.attachments') }}</h3>
<ul
v-if="attachments.length"
class="attachment-list"
>
<li
v-for="attachment in attachments"
:key="attachment.key"
>
<i
class="pi pi-paperclip"
aria-hidden="true"
/>
<span>{{ attachment.name }}</span>
<small v-if="attachment.meta">{{ attachment.meta }}</small>
</li>
</ul>
<p v-else>
{{ t('taskV4.sourceMessage.noAttachments') }}
</p>
</div>
<ReservationV4TaskCardFieldRenderer
v-if="sourceCard?.fields.length"
:fields="sourceCard.fields"
:model-value="fieldValues"
:read-only="true"
/>
<RouterLink
v-if="showConversationLink && sourceMessageId"
class="source-link"
:to="`/reservation/source-messages/${sourceMessageId}/conversation`"
>
{{ t('task.viewConversation') }}
</RouterLink>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
import type {
ReservationRecord,
ReservationV4SourceMessageSummary,
ReservationV4TaskCardResult,
} from '@/types/reservation'
import { formatReservationDateTime } from '@/utils/reservationFormat'
import { buildReservationV4InitialFieldValues } from '@/utils/reservationV4FieldRules'
const props = withDefaults(defineProps<{
title: string
sourceMessageSummary: ReservationV4SourceMessageSummary | null
sourceCard: ReservationV4TaskCardResult | null
showConversationLink?: boolean
}>(), {
showConversationLink: true,
})
const { t } = useI18n()
const payload = computed(() => props.sourceCard?.display_payload ?? {})
const fieldValues = computed(() => buildReservationV4InitialFieldValues(props.sourceCard?.fields ?? []))
const sourceMessageId = computed(() =>
props.sourceMessageSummary?.source_message_id ?? readString(payload.value, 'source_message_id'),
)
const sourceSubject = computed(() =>
props.sourceMessageSummary?.subject ??
readFirstString(payload.value, ['subject', 'thread_subject', 'source_subject']) ??
t('taskList.noSourceSubject'),
)
const sourceSender = computed(() =>
props.sourceMessageSummary?.sender_summary ??
readFirstString(payload.value, ['sender_summary', 'sender', 'from']) ??
'-',
)
const sourceReceivedAt = computed(() =>
props.sourceMessageSummary?.received_at ??
readFirstString(payload.value, ['received_at', 'source_received_at', 'source_sent_at']),
)
const conversationMessageCount = computed(() =>
props.sourceMessageSummary?.conversation_message_count ??
readNumber(payload.value, 'conversation_message_count') ??
'-',
)
const sourceExcerpt = computed(() =>
readFirstString(payload.value, [
'relevant_message_excerpt',
'message_excerpt',
'text_excerpt',
'excerpt',
'summary',
]) ?? t('taskV4.sourceMessage.noExcerpt'),
)
const attachments = computed(() => normalizeAttachments(readFirstDefined(payload.value, [
'attachments',
'uploaded_media',
'file_references',
])))
interface AttachmentSummary {
key: string
name: string
meta: string
}
function normalizeAttachments(value: unknown): AttachmentSummary[] {
if (value === null || value === undefined || value === '') {
return []
}
if (Array.isArray(value)) {
return value.flatMap((item, index) =>
normalizeAttachments(item).map((attachment, nestedIndex) => ({
...attachment,
key: `${attachment.key}-${index}-${nestedIndex}`,
})),
)
}
if (typeof value === 'string') {
const parsedValue = parseMaybeJson(value)
return parsedValue === value ? [toAttachmentSummary(value, 0)] : normalizeAttachments(parsedValue)
}
return [toAttachmentSummary(value, 0)]
}
function toAttachmentSummary(value: unknown, index: number): AttachmentSummary {
const record = readRecord(value)
const name = record
? readFirstString(record, ['name', 'file_name', 'filename', 'display_name', 'title']) ?? t('conversation.unnamedAttachment')
: String(value)
const type = record ? readFirstString(record, ['content_type', 'mime_type', 'media_type', 'file_type']) : null
const size = record ? readNumber(record, 'size_bytes') ?? readNumber(record, 'size') : null
return {
key: `${name}-${index}`,
name,
meta: [type, formatFileSize(size)].filter(Boolean).join(' · '),
}
}
function parseMaybeJson(value: string): unknown {
const trimmedValue = value.trim()
if (!trimmedValue.startsWith('{') && !trimmedValue.startsWith('[')) {
return value
}
try {
return JSON.parse(trimmedValue)
} catch {
return value
}
}
function readFirstDefined(record: ReservationRecord, keys: string[]): unknown {
for (const key of keys) {
if (record[key] !== undefined && record[key] !== null && record[key] !== '') {
return record[key]
}
}
return null
}
function readFirstString(record: ReservationRecord, keys: string[]): string | null {
for (const key of keys) {
const value = readString(record, key)
if (value) {
return value
}
}
return null
}
function readString(record: ReservationRecord, key: string): string | null {
const value = record[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function readNumber(record: ReservationRecord, key: string): number | null {
const value = record[key]
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && Number.isFinite(Number(value))) {
return Number(value)
}
return null
}
function readRecord(value: unknown): ReservationRecord | null {
return Boolean(value && typeof value === 'object' && !Array.isArray(value)) ? value as ReservationRecord : null
}
function formatFileSize(size: number | null): string {
if (!size) {
return ''
}
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${Math.round(size / 1024)} KB`
}
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
</script>
<style scoped>
.v4-source-card {
display: grid;
gap: 18px;
}
.source-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 0 20px;
}
.source-grid div {
display: grid;
gap: 5px;
}
.source-grid span,
.source-preview h3 {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.source-grid strong,
.source-preview p {
margin: 0;
color: var(--th-color-slate-900);
font-size: 13px;
overflow-wrap: anywhere;
}
.source-preview {
display: grid;
gap: 8px;
border-top: 1px solid var(--th-color-slate-200);
padding: 14px 20px 0;
}
.source-preview h3 {
margin: 0;
}
.attachment-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
}
.attachment-list li {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-slate-50);
color: var(--th-color-slate-900);
list-style: none;
padding: 8px 10px;
}
.attachment-list small {
color: var(--th-color-slate-500);
}
.v4-field-renderer {
padding: 0 20px 18px;
}
.source-link {
justify-self: start;
margin: 0 20px 18px;
color: var(--th-color-blue-600);
font-size: 13px;
font-weight: 800;
}
@media (max-width: 900px) {
.source-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,269 @@
<template>
<div class="v4-field-renderer">
<label
v-for="field in fields"
:key="fieldKey(field)"
class="v4-field"
:class="{ 'v4-field--readonly': !isEditable(field) }"
>
<span class="v4-field__label">
{{ field.display_name }}
<sup v-if="field.required">*</sup>
<em v-if="!isEditable(field)">{{ t('taskV4.field.readonly') }}</em>
</span>
<select
v-if="isEditable(field) && isSelectField(field)"
class="v4-field__control"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@change="updateField(field, $event)"
>
<option value="">{{ t('taskV4.field.selectPlaceholder') }}</option>
<option
v-for="option in fieldOptions(field)"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<input
v-else-if="isEditable(field) && isDateField(field)"
class="v4-field__control"
type="date"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<input
v-else-if="isEditable(field) && isNumberField(field)"
class="v4-field__control"
type="number"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<textarea
v-else-if="isEditable(field) && isTextAreaField(field)"
class="v4-field__control v4-field__control--textarea"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
rows="3"
@input="updateField(field, $event)"
/>
<input
v-else-if="isEditable(field)"
class="v4-field__control"
type="text"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<span
v-else
class="v4-field__static"
>
{{ stringifyV4Value(fieldValue(field)) }}
</span>
<small
v-if="field.control_hint"
class="v4-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="error in visibleFieldErrors(field)"
:key="error"
class="v4-field__error"
>{{ error }}</small>
</label>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ReservationRecord, ReservationV4TaskCardFieldResult } from '@/types/reservation'
import {
isReservationV4EditableField,
readV4FieldValue,
reservationV4FieldKey,
staticReservationV4Options,
stringifyReservationV4SafeDisplayValue,
} from '@/utils/reservationV4FieldRules'
const props = defineProps<{
fields: ReservationV4TaskCardFieldResult[]
modelValue: ReservationRecord
readOnly: boolean
validationErrors?: Record<string, string>
editableKeys?: string[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: ReservationRecord]
}>()
const { t } = useI18n()
function fieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
}
function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
const key = reservationV4FieldKey(field)
return !props.readOnly &&
(!props.editableKeys || props.editableKeys.includes(key)) &&
isReservationV4EditableField(field)
}
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'SELECT' && fieldOptions(field).length > 0
}
function isDateField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'DATE'
}
function isNumberField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'NUMBER'
}
function isTextAreaField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'TEXTAREA'
}
function fieldOptions(field: ReservationV4TaskCardFieldResult): Array<{ value: string; label: string }> {
return staticReservationV4Options(field.options_source)
}
function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
const value = readV4FieldValue(field, props.modelValue)
if (!isEditable(field)) {
return stringifyV4Value(value)
}
if (typeof value === 'number') {
return value
}
if (typeof value === 'string') {
return value
}
return stringifyV4Value(value)
}
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
emit('update:modelValue', {
...props.modelValue,
[reservationV4FieldKey(field)]: target.value,
})
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
return [
...(field.validation_errors ?? []),
fieldError(field),
].filter(Boolean)
}
function fieldError(field: ReservationV4TaskCardFieldResult): string {
return props.validationErrors?.[reservationV4FieldKey(field)] ?? ''
}
function stringifyV4Value(value: unknown): string {
return stringifyReservationV4SafeDisplayValue(
value,
t('taskV4.field.empty'),
t('taskV4.field.hidden'),
)
}
function normalizeControlType(value: string | null): string {
return value?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? ''
}
</script>
<style scoped>
.v4-field-renderer {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.v4-field {
display: grid;
gap: 7px;
}
.v4-field__label {
display: flex;
align-items: center;
gap: 6px;
color: var(--th-color-slate-700);
font-size: 12px;
font-weight: 800;
}
.v4-field__label sup {
color: var(--th-color-danger);
}
.v4-field__label em {
border-radius: 999px;
background: var(--th-color-slate-100);
color: var(--th-color-slate-500);
font-size: 11px;
font-style: normal;
padding: 2px 6px;
}
.v4-field__control,
.v4-field__static {
min-height: 38px;
width: 100%;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: var(--th-color-slate-900);
font: inherit;
padding: 9px 10px;
}
.v4-field__control--textarea {
resize: vertical;
}
.v4-field__control[aria-invalid='true'] {
border-color: var(--th-color-danger);
box-shadow: 0 0 0 3px var(--th-color-danger-bg);
}
.v4-field__static {
display: block;
min-height: 38px;
background: var(--th-color-slate-50);
overflow-wrap: anywhere;
}
.v4-field__hint {
color: var(--th-color-slate-500);
font-size: 12px;
}
.v4-field__error {
color: var(--th-color-danger);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 760px) {
.v4-field-renderer {
grid-template-columns: 1fr;
}
}
</style>