接入M002V4订单任务多卡前端
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user