feat: remaining frontend views (groups/calls/sms/sync) + messageout media fields

Backend (Part A):
- MessageOut: add file_th_url, file_hd_url, duration, transcription_text, md5
  (room_id + sender_head_url already existed) so the chat timeline can render
  image thumbnails, voice duration/transcription, and avatars from a single
  response.
- PhoneCallOut: add record_file so the Calls view can offer recording playback.
- SmsOut: add content (full body) so the SMS detail dialog shows the whole text.
- Tests: red->green for each new field (media fields default None + populated,
  record_file round-trip, content round-trip). Suite 267 -> 271.

Frontend (Part B):
- api/index.ts: wrappers getGroups/getGroupMembers/getCalls/getSms/getSyncStatus
  + Group/GroupMember/PhoneCall/Sms/SyncState/SyncRun/SyncStatus types in
  types.ts (mirror the *Out pydantic schemas).
- views/GroupList.vue (/groups): table + channel filter + pagination; row click
  opens the group timeline.
- views/CallList.vue (/calls): table + call-type filter (phone_call/voice_call/
  all); "播放录音" action opens an el-dialog with <audio controls>.
- views/SmsList.vue (/sms): table; row click opens a detail dialog with the full
  content.
- views/SyncStatus.vue (/sync): two tables (states + recent_runs) with a manual
  refresh; run status color-coded.
- ChatTimeline.vue: reused for both 1:1 DM (talkerId) and group (roomId) by
  branching the query; group title + per-member sender already handled by
  MessageBubble.
- router: add /groups /calls /sms /sync + /group/:channel/:ownerId/:roomId.
- layouts/MainLayout.vue: replace the single 客户列表 button with an el-menu
  (客户 / 群 / 通话 / 短信 / 同步); org tree kept on the left.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-01 16:38:47 +08:00
parent 6c4cd92ae6
commit b822e87d4d
14 changed files with 1401 additions and 36 deletions
+243
View File
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getCalls } from '@/api'
import type { CallChannel, PhoneCall } from '@/api/types'
import {
callDirectionLabel,
callStatusLabel,
callTypeLabel,
formatCallDuration,
formatTime,
} from '@/utils/format'
// Calls aren't tied to a messaging channel — `channel` on a call is the call
// type (phone_call / voice_call). We keep a LOCAL filter so it doesn't fight
// the shared top-bar wechat/whatsapp store used by customers/groups.
const callType = ref<CallChannel | 'all'>('all')
const calls = ref<PhoneCall[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// Recording playback dialog state.
const recordDialogVisible = ref(false)
const recordSrc = ref<string | null>(null)
const recordTitle = ref('')
const typeOptions: { value: CallChannel | 'all'; label: string }[] = [
{ value: 'all', label: '全部类型' },
{ value: 'phone_call', label: '电话' },
{ value: 'voice_call', label: '语音通话' },
]
const typeLabelCurrent = computed(() => {
const opt = typeOptions.find((o) => o.value === callType.value)
return opt ? opt.label : '全部类型'
})
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getCalls({
channel: callType.value,
page: page.value,
page_size: pageSize.value,
})
calls.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`通话记录加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
function onTypeChange(value: string | number | boolean | undefined): void {
callType.value = value as CallChannel | 'all'
page.value = 1
load()
}
function playRecord(row: PhoneCall): void {
if (!row.record_file) {
ElMessage.info('该通话无录音文件')
return
}
recordSrc.value = row.record_file
const who = row.user_id_name || row.user_id || row.call_id
const when = formatTime(row.start_call_time)
recordTitle.value = `录音 · ${who}${when !== '--' ? ` · ${when}` : ''}`
recordDialogVisible.value = true
}
onMounted(load)
</script>
<template>
<div class="yk-calls">
<div class="yk-calls__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">通话记录</h2>
<span class="yk-calls__sub">
共 {{ total }} 条 · 类型:{{ typeLabelCurrent }}
</span>
</div>
<div class="yk-calls__filters">
<el-radio-group :model-value="callType" size="small" @change="onTypeChange">
<el-radio-button
v-for="opt in typeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="calls"
stripe
border
row-key="call_id"
style="width: 100%"
empty-text="暂无通话记录"
>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.start_call_time) }}</template>
</el-table-column>
<el-table-column label="员工" min-width="130">
<template #default="{ row }">
{{ row.user_id_name || row.user_id || '--' }}
</template>
</el-table-column>
<el-table-column label="号码" min-width="130">
<template #default="{ row }">
{{ row.call_number || row.sim_phone || '--' }}
</template>
</el-table-column>
<el-table-column label="方向" width="80">
<template #default="{ row }">{{ callDirectionLabel(row.incoming_call) }}</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ callTypeLabel(row.channel) }}</template>
</el-table-column>
<el-table-column label="时长" width="90" align="right">
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">{{ callStatusLabel(row.call_status) }}</template>
</el-table-column>
<el-table-column label="录音" width="100" align="center">
<template #default="{ row }">
<el-button
v-if="row.record_file"
size="small"
type="primary"
link
@click.stop="playRecord(row)"
>
播放录音
</el-button>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
</el-table>
<div class="yk-calls__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<el-dialog
v-model="recordDialogVisible"
:title="recordTitle"
width="480px"
align-center
@closed="recordSrc = null"
>
<audio
v-if="recordSrc"
controls
:src="recordSrc"
style="width: 100%"
preload="metadata"
>
您的浏览器不支持音频播放。
</audio>
<div v-if="recordSrc" class="yk-calls__record-link">
<a :href="recordSrc" target="_blank" rel="noopener">在新窗口打开录音文件</a>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.yk-calls__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-calls__sub {
color: #909399;
font-size: 13px;
}
.yk-calls__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-calls__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-calls__record-link {
margin-top: 8px;
font-size: 12px;
text-align: center;
}
</style>
+35 -26
View File
@@ -8,10 +8,19 @@ import type { Channel, Message } from '@/api/types'
import MessageBubble from '@/components/MessageBubble.vue'
import { channelLabel } from '@/utils/format'
/**
* Chat timeline. Reused for two conversation shapes:
* * 1:1 DM — `talkerId` set; messages filtered by `talker_id`.
* * Group — `roomId` set; messages filtered by `room_id`. Group messages
* carry per-member `sender_name` / `sender_head_url`, so each
* bubble identifies its sender (handled by MessageBubble).
* Exactly one of `talkerId` / `roomId` is normally present.
*/
const props = defineProps<{
channel: string
ownerId: string
talkerId: string
talkerId?: string
roomId?: string
}>()
const router = useRouter()
@@ -31,11 +40,17 @@ const total = ref(0)
const channelTyped = computed(() => props.channel as Channel)
/** True when this timeline is a group chat (drives the query + title). */
const isGroup = computed(() => !!props.roomId)
const hasMoreOlder = computed(() => currentPage.value > 1)
const conversationTitle = computed(
() => `与 ${props.talkerId} 的聊天记录`,
)
const conversationTitle = computed(() => {
if (isGroup.value) {
return `群聊记录 · ${props.roomId}`
}
return `与 ${props.talkerId} 的聊天记录`
})
const subtitle = computed(
() =>
@@ -52,6 +67,18 @@ function scrollToBottom(): void {
})
}
/** Shared query params for both DM and group timelines. */
function queryFor(pageNum: number) {
return {
channel: channelTyped.value,
owner_account_id: props.ownerId,
talker_id: isGroup.value ? undefined : props.talkerId,
room_id: isGroup.value ? props.roomId : undefined,
page: pageNum,
page_size: PAGE_SIZE,
}
}
/**
* Fetch the last page (most recent messages) on entry. Page 1 is fetched first
* purely to learn `total` so we can compute `lastPage` — the messages API
@@ -61,26 +88,14 @@ async function loadInitial(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const first = await getMessages({
channel: channelTyped.value,
owner_account_id: props.ownerId,
talker_id: props.talkerId,
page: 1,
page_size: PAGE_SIZE,
})
const first = await getMessages(queryFor(1))
total.value = first.total
totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE))
if (totalPages.value <= 1) {
messages.value = first.items
} else {
const last = await getMessages({
channel: channelTyped.value,
owner_account_id: props.ownerId,
talker_id: props.talkerId,
page: totalPages.value,
page_size: PAGE_SIZE,
})
const last = await getMessages(queryFor(totalPages.value))
messages.value = last.items
}
currentPage.value = totalPages.value
@@ -100,13 +115,7 @@ async function loadOlder(): Promise<void> {
const prevHeight = streamRef.value?.scrollHeight ?? 0
try {
const prevPage = currentPage.value - 1
const res = await getMessages({
channel: channelTyped.value,
owner_account_id: props.ownerId,
talker_id: props.talkerId,
page: prevPage,
page_size: PAGE_SIZE,
})
const res = await getMessages(queryFor(prevPage))
// Backend returns asc, so res.items are older than everything currently
// shown — prepend them.
messages.value = [...res.items, ...messages.value]
@@ -134,7 +143,7 @@ function goBack(): void {
// Reload when the route params change (navigating between conversations).
watch(
() => [props.channel, props.ownerId, props.talkerId] as const,
() => [props.channel, props.ownerId, props.talkerId, props.roomId] as const,
() => {
loadInitial()
},
+222
View File
@@ -0,0 +1,222 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getGroups } from '@/api'
import type { Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
const router = useRouter()
const channelStore = useChannelStore()
const groups = ref<Group[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
const channelOptions = [
{ value: 'all' as const, label: '全部渠道' },
{ value: 'wechat' as const, label: '微信' },
{ value: 'whatsapp' as const, label: 'WhatsApp' },
]
const channelLabelCurrent = computed(() =>
channelLabel(channelStore.channel === 'all' ? null : channelStore.channel),
)
function channelTagType(c: Group['channel']) {
return c === 'wechat' ? 'success' : 'warning'
}
function onChannelChange(value: string | number | boolean | undefined): void {
channelStore.setChannel(value as Channel | 'all')
}
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getGroups({
channel: channelStore.channel,
page: page.value,
page_size: pageSize.value,
})
groups.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`群列表加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
// React to the top-bar channel filter.
watch(
() => channelStore.channel,
() => {
page.value = 1
load()
},
)
function openTimeline(row: Group): void {
// Group messages are queried by room_id; the timeline view branches on the
// presence of a `roomId` route param to issue a room_id query.
if (!row.owner_account_id) {
ElMessage.warning('该群缺少归属账号,无法查看聊天记录')
return
}
router.push({
name: 'group-chat',
params: {
channel: row.channel,
ownerId: row.owner_account_id,
roomId: row.room_id,
},
})
}
onMounted(load)
</script>
<template>
<div class="yk-groups">
<div class="yk-groups__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">群列表</h2>
<span class="yk-groups__sub">
共 {{ total }} 条 · 渠道:{{ channelLabelCurrent }}
</span>
</div>
<div class="yk-groups__filters">
<el-radio-group
:model-value="channelStore.channel"
size="small"
@change="onChannelChange"
>
<el-radio-button
v-for="opt in channelOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="groups"
stripe
border
row-key="room_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无群数据"
@row-click="openTimeline"
>
<el-table-column label="群名" min-width="180">
<template #default="{ row }">
{{ row.room_name || row.room_id }}
</template>
</el-table-column>
<el-table-column label="渠道" width="100">
<template #default="{ row }">
<el-tag :type="channelTagType(row.channel)" size="small" effect="plain">
{{ channelLabel(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="成员数" prop="member_count" width="90" align="right">
<template #default="{ row }">{{ row.member_count ?? '--' }}</template>
</el-table-column>
<el-table-column label="消息数" prop="message_count" width="90" align="right">
<template #default="{ row }">{{ row.message_count ?? '--' }}</template>
</el-table-column>
<el-table-column label="群主" min-width="140">
<template #default="{ row }">
<span v-if="row.owner_account_nickname">{{ row.owner_account_nickname }}</span>
<span v-else-if="row.owner_account_id">{{ row.owner_account_id }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="最近聊天" width="160">
<template #default="{ row }">{{ formatTime(row.last_chat_time) }}</template>
</el-table-column>
</el-table>
<div class="yk-groups__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-groups__hint">点击任意一行查看该群的聊天记录。</p>
</div>
</template>
<style scoped>
.yk-groups__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-groups__sub {
color: #909399;
font-size: 13px;
}
.yk-groups__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-groups__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-groups__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getSms } from '@/api'
import type { Sms } from '@/api/types'
import { formatTime, smsDirectionLabel } from '@/utils/format'
const list = ref<Sms[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// Detail dialog state — shows the full SMS body (vs the truncated preview in
// the table).
const detailVisible = ref(false)
const detailRow = ref<Sms | null>(null)
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getSms({
page: page.value,
page_size: pageSize.value,
})
list.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`短信记录加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
function openDetail(row: Sms): void {
detailRow.value = row
detailVisible.value = true
}
onMounted(load)
</script>
<template>
<div class="yk-sms">
<div class="yk-sms__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">短信记录</h2>
<span class="yk-sms__sub">共 {{ total }} 条</span>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="list"
stripe
border
row-key="sms_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无短信记录"
@row-click="openDetail"
>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="员工" min-width="130">
<template #default="{ row }">
{{ row.receive_name || row.user_id || '--' }}
</template>
</el-table-column>
<el-table-column label="客户号码" min-width="130">
<template #default="{ row }">
{{ row.customer_phone || row.sender || '--' }}
</template>
</el-table-column>
<el-table-column label="方向" width="80">
<template #default="{ row }">{{ smsDirectionLabel(row.direction) }}</template>
</el-table-column>
<el-table-column label="内容预览" min-width="260">
<template #default="{ row }">
<span v-if="row.content_preview">{{ row.content_preview }}</span>
<span v-else-if="row.content">{{ row.content }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
</el-table>
<div class="yk-sms__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-sms__hint">点击任意一行查看完整短信内容。</p>
<el-dialog
v-model="detailVisible"
title="短信详情"
width="520px"
align-center
>
<div v-if="detailRow" class="yk-sms__detail">
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">时间</span>
<span>{{ formatTime(detailRow.create_time) }}</span>
</div>
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">员工</span>
<span>{{ detailRow.receive_name || detailRow.user_id || '--' }}</span>
</div>
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">客户号码</span>
<span>{{ detailRow.customer_phone || '--' }}</span>
</div>
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">发送方</span>
<span>{{ detailRow.sender || '--' }}</span>
</div>
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">方向</span>
<span>{{ smsDirectionLabel(detailRow.direction) }}</span>
</div>
<div class="yk-sms__detail-row yk-sms__detail-row--top">
<span class="yk-sms__detail-label">内容</span>
<span class="yk-sms__detail-content">{{
detailRow.content || detailRow.content_preview || '(空)'
}}</span>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.yk-sms__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-sms__sub {
color: #909399;
font-size: 13px;
}
.yk-sms__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-sms__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
.yk-sms__detail {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
}
.yk-sms__detail-row {
display: flex;
gap: 12px;
align-items: center;
}
.yk-sms__detail-row--top {
align-items: flex-start;
}
.yk-sms__detail-label {
flex: 0 0 64px;
color: #909399;
}
.yk-sms__detail-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getSyncStatus } from '@/api'
import type { SyncRun, SyncState } from '@/api/types'
import { formatTime } from '@/utils/format'
const states = ref<SyncState[]>([])
const recentRuns = ref<SyncRun[]>([])
const loading = ref(false)
const errorMsg = ref<string | null>(null)
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getSyncStatus()
states.value = res.states
recentRuns.value = res.recent_runs
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`同步状态加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
/** Map a run status to a tag type for color coding. */
function runStatusTagType(status: string): 'success' | 'warning' | 'danger' | 'info' {
switch (status) {
case 'success':
return 'success'
case 'running':
return 'warning'
case 'failed':
return 'danger'
default:
return 'info'
}
}
/** last_timestamp is a ms-epoch; last_synced_at is an ISO datetime string. */
function isoToTime(iso?: string | null): string {
if (!iso) return '--'
const t = Date.parse(iso)
return Number.isNaN(t) ? '--' : formatTime(t)
}
onMounted(load)
</script>
<template>
<div class="yk-sync" v-loading="loading">
<div class="yk-sync__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">同步状态</h2>
<span class="yk-sync__sub">
游标 {{ states.length }} 条 · 最近执行 {{ recentRuns.length }} 条
</span>
</div>
<el-button :icon="Refresh" size="small" @click="load">刷新</el-button>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<h3 class="yk-sync__section">同步游标</h3>
<el-table
:data="states"
stripe
border
row-key="state_id"
style="width: 100%"
empty-text="暂无游标"
>
<el-table-column label="实体" prop="entity_type" min-width="110" />
<el-table-column label="渠道" min-width="90">
<template #default="{ row }">{{ row.channel ?? '--' }}</template>
</el-table-column>
<el-table-column label="作用域" min-width="140">
<template #default="{ row }">{{ row.scope_id ?? '--' }}</template>
</el-table-column>
<el-table-column label="最近版本" min-width="110">
<template #default="{ row }">{{ row.last_version ?? '--' }}</template>
</el-table-column>
<el-table-column label="最近时间戳" width="170">
<template #default="{ row }">{{ formatTime(row.last_timestamp) }}</template>
</el-table-column>
<el-table-column label="最近同步时间" width="180">
<template #default="{ row }">{{ isoToTime(row.last_synced_at) }}</template>
</el-table-column>
</el-table>
<h3 class="yk-sync__section">最近执行</h3>
<el-table
:data="recentRuns"
stripe
border
row-key="run_id"
style="width: 100%"
empty-text="暂无执行记录"
>
<el-table-column label="实体" prop="entity_type" min-width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="runStatusTagType(row.status)" size="small" effect="plain">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="渠道" min-width="90">
<template #default="{ row }">{{ row.channel ?? '--' }}</template>
</el-table-column>
<el-table-column label="作用域" min-width="130">
<template #default="{ row }">{{ row.scope_id ?? '--' }}</template>
</el-table-column>
<el-table-column label="开始时间" width="180">
<template #default="{ row }">{{ isoToTime(row.started_at) }}</template>
</el-table-column>
<el-table-column label="结束时间" width="180">
<template #default="{ row }">{{ isoToTime(row.finished_at) }}</template>
</el-table-column>
<el-table-column label="拉取/写入" width="120" align="right">
<template #default="{ row }">
{{ row.records_fetched }} / {{ row.records_upserted }}
</template>
</el-table-column>
<el-table-column label="错误" min-width="180">
<template #default="{ row }">
<span v-if="row.error" class="yk-sync__error">{{ row.error }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.yk-sync__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-sync__sub {
color: #909399;
font-size: 13px;
}
.yk-sync__section {
font-size: 14px;
font-weight: 600;
margin: 20px 0 8px;
color: #303133;
}
.yk-sync__error {
color: #f56c6c;
word-break: break-word;
}
</style>