Files
loveKeyAdmin/src/views/keyboard/aicompanion/index.vue

900 lines
26 KiB
Vue
Raw Normal View History

2026-04-03 16:32:59 +08:00
<template>
<!-- <ContentWrap class="page-hero">
<div class="hero-panel">
<div>
<div class="hero-eyebrow">AI Companion Admin</div>
<div class="hero-title">陪聊角色管理</div>
<div class="hero-subtitle">
聚焦角色筛选上线状态和人设浏览降低长表格的阅读负担
</div>
</div>
<div class="hero-metrics">
<div class="metric-card">
<div class="metric-label">当前页角色</div>
<div class="metric-value">{{ list.length }}</div>
</div>
<div class="metric-card">
<div class="metric-label">在线角色</div>
<div class="metric-value">{{ onlineCount }}</div>
</div>
<div class="metric-card">
<div class="metric-label">公开角色</div>
<div class="metric-value">{{ publicCount }}</div>
</div>
<div class="metric-card accent">
<div class="metric-label">已选中</div>
<div class="metric-value">{{ checkedIds.length }}</div>
</div>
</div>
</div>
</ContentWrap> -->
2026-02-10 15:20:30 +08:00
<ContentWrap>
2026-04-03 16:32:59 +08:00
<div class="toolbar-header">
<div>
<div class="section-title">筛选条件</div>
<div class="section-desc">保留高频条件直出低频条件收进高级筛选</div>
</div>
<el-button text type="primary" @click="showAdvancedFilters = !showAdvancedFilters">
<Icon :icon="showAdvancedFilters ? 'ep:arrow-up' : 'ep:arrow-down'" class="mr-4px" />
{{ showAdvancedFilters ? '收起高级筛选' : '展开高级筛选' }}
</el-button>
</div>
<el-form ref="queryFormRef" :model="queryParams" label-width="90px" class="filter-form">
<div class="filter-grid">
<el-form-item label="性别" prop="gender">
<el-select v-model="queryParams.gender" placeholder="全部性别" clearable filterable class="!w-full">
<el-option label="Male" value="male" />
<el-option label="Female" value="female" />
<el-option label="Other" value="other" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部状态" clearable class="!w-full">
<el-option label="在线" :value="1" />
<el-option label="离线" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="可见性" prop="visibility">
<el-select v-model="queryParams.visibility" placeholder="全部可见性" clearable class="!w-full">
<el-option label="公开" :value="1" />
<el-option label="内测" :value="2" />
<el-option label="隐藏" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="性格标签" prop="personalityTags">
<el-input v-model="queryParams.personalityTags" placeholder="如:温柔、治愈" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="说话风格" prop="speakingStyle">
<el-input v-model="queryParams.speakingStyle" placeholder="如:理性、活泼" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="语音 ID" prop="voiceId">
<el-input v-model="queryParams.voiceId" placeholder="请输入 voiceId" clearable @keyup.enter="handleQuery" />
</el-form-item>
</div>
<el-collapse-transition>
<div v-show="showAdvancedFilters" class="advanced-panel">
<div class="filter-grid">
<el-form-item label="年龄段" prop="ageRange">
<el-input v-model="queryParams.ageRange" placeholder="如20s、25-30" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="系统 Prompt" prop="systemPrompt">
<el-input v-model="queryParams.systemPrompt" placeholder="按核心人设搜索" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="开场白" prop="prologue">
<el-input v-model="queryParams.prologue" placeholder="搜索开场白内容" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="热度分" prop="popularityScore">
<el-input v-model="queryParams.popularityScore" placeholder="按热度分搜索" clearable
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="排序权重" prop="sortOrder">
<el-input v-model="queryParams.sortOrder" placeholder="按排序权重搜索" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="创建时间" prop="createdAt">
<el-date-picker v-model="queryParams.createdAt" type="date" value-format="YYYY-MM-DD" placeholder="选择创建日期"
clearable class="!w-full" />
</el-form-item>
<el-form-item label="更新时间" prop="updatedAt">
<el-date-picker v-model="queryParams.updatedAt" type="date" value-format="YYYY-MM-DD" placeholder="选择更新日期"
clearable class="!w-full" />
</el-form-item>
<el-form-item label="头像 URL" prop="avatarUrl">
<el-input v-model="queryParams.avatarUrl" placeholder="匹配头像 URL" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="封面 URL" prop="coverImageUrl">
<el-input v-model="queryParams.coverImageUrl" placeholder="匹配封面 URL" clearable
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="开场白音频" prop="prologueAudio">
<el-input v-model="queryParams.prologueAudio" placeholder="匹配音频地址" clearable @keyup.enter="handleQuery" />
</el-form-item>
</div>
</div>
</el-collapse-transition>
<div class="toolbar-actions">
<div class="action-group">
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
</div>
<div class="action-group">
<el-button type="primary" @click="openForm('create')" v-hasPermi="['keyboard:ai-companion:create']">
<Icon icon="ep:plus" class="mr-5px" />
新增角色
</el-button>
<el-button type="success" plain @click="handleExport" :loading="exportLoading"
v-hasPermi="['keyboard:ai-companion:export']">
<Icon icon="ep:download" class="mr-5px" />
导出
</el-button>
<el-button type="danger" plain :disabled="isEmpty(checkedIds)" @click="handleDeleteBatch"
v-hasPermi="['keyboard:ai-companion:delete']">
<Icon icon="ep:delete" class="mr-5px" />
批量删除
</el-button>
</div>
</div>
2026-02-10 15:20:30 +08:00
</el-form>
</ContentWrap>
<ContentWrap>
2026-04-03 16:32:59 +08:00
<div class="toolbar-header">
<div>
<div class="section-title">角色列表</div>
<div class="section-desc">
{{ total }} 条记录当前展示 {{ list.length }}
</div>
</div>
<div class="selection-indicator" :class="{ active: checkedIds.length > 0 }">
<Icon icon="ep:select" class="mr-6px" />
已勾选 {{ checkedIds.length }}
</div>
</div>
<el-table row-key="id" v-loading="loading" :data="list" stripe highlight-current-row :show-overflow-tooltip="false"
@current-change="handleCurrentChange" @selection-change="handleRowCheckboxChange">
<el-table-column type="selection" width="52" />
<el-table-column label="角色信息" min-width="300">
<template #default="{ row }">
<div class="companion-cell">
<el-image v-if="row.coverImageUrl" :src="row.coverImageUrl" :preview-src-list="buildPreviewList(row)"
fit="cover" class="cover-image" preview-teleported />
<div v-else class="cover-image cover-image--empty">无封面</div>
<div class="companion-main">
<div class="companion-head">
<el-avatar v-if="row.avatarUrl" :src="row.avatarUrl" :size="42" />
<div v-else class="avatar-fallback">AI</div>
<div>
<div class="companion-id">ID {{ row.id }}</div>
<div class="companion-meta">
<span>{{ formatGender(row.gender) }}</span>
<span>{{ row.ageRange || '年龄未填' }}</span>
</div>
</div>
</div>
<div class="tag-row">
<el-tag v-for="tag in getTagList(row.personalityTags)" :key="tag" size="small" effect="plain" round>
{{ tag }}
</el-tag>
<span v-if="getTagList(row.personalityTags).length === 0" class="muted-text">
暂无标签
</span>
</div>
</div>
</div>
2026-02-10 15:20:30 +08:00
</template>
</el-table-column>
2026-04-03 16:32:59 +08:00
<el-table-column label="人设概览" min-width="330">
<template #default="{ row }">
<div class="stack-text">
<div class="stack-title">{{ row.speakingStyle || '未填写说话风格' }}</div>
<div class="stack-desc line-clamp-2">
{{ row.systemPrompt || '暂无系统 Prompt' }}
</div>
</div>
2026-02-10 15:20:30 +08:00
</template>
</el-table-column>
2026-04-03 16:32:59 +08:00
<el-table-column label="状态" width="170" align="center">
<template #default="{ row }">
<div class="status-stack">
<el-tag :type="getStatusMeta(row.status).type" round>
{{ getStatusMeta(row.status).label }}
</el-tag>
<el-tag :type="getVisibilityMeta(row.visibility).type" effect="plain" round>
{{ getVisibilityMeta(row.visibility).label }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="排序 / 热度" width="150" align="center">
<template #default="{ row }">
<div class="score-stack">
<div>排序 {{ row.sortOrder ?? '-' }}</div>
<div class="score-highlight">热度 {{ row.popularityScore ?? '-' }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="开场白" min-width="260">
<template #default="{ row }">
<div class="line-clamp-3">{{ row.prologue || '暂无开场白' }}</div>
</template>
</el-table-column>
<el-table-column label="语音" min-width="180">
<template #default="{ row }">
<div class="stack-text">
<div class="stack-title mono-text">{{ row.voiceId || '-' }}</div>
<div class="stack-desc line-clamp-1">{{ row.prologueAudio || '未配置开场音频' }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180" align="center">
<template #default="{ row }">
{{ dateFormatter(row.updatedAt) || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openForm('update', row.id)"
2026-04-03 16:04:02 +08:00
v-hasPermi="['keyboard:ai-companion:update']">
2026-02-10 15:20:30 +08:00
编辑
</el-button>
2026-04-03 16:32:59 +08:00
<el-button link type="danger" @click="handleDelete(row.id)" v-hasPermi="['keyboard:ai-companion:delete']">
2026-02-10 15:20:30 +08:00
删除
</el-button>
</template>
</el-table-column>
</el-table>
2026-04-03 16:32:59 +08:00
2026-04-03 16:04:02 +08:00
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
2026-02-10 15:20:30 +08:00
</ContentWrap>
2026-04-03 16:04:02 +08:00
<ContentWrap>
2026-04-03 16:32:59 +08:00
<div class="toolbar-header">
<div>
<div class="section-title">角色详情</div>
<div class="section-desc">点击表格行查看当前角色的人设摘要和国际化内容</div>
</div>
<div v-if="currentRow.id" class="selection-indicator active">
当前查看 ID {{ currentRow.id }}
</div>
</div>
<el-empty v-if="!currentRow.id" description="请选择上方列表中的一条角色记录" :image-size="120" />
<template v-else>
<div class="detail-banner">
<el-avatar v-if="currentRow.avatarUrl" :src="currentRow.avatarUrl" :size="56" />
<div v-else class="avatar-fallback avatar-fallback--large">AI</div>
<div class="detail-banner__content">
<div class="detail-banner__title">
角色 #{{ currentRow.id }}
<el-tag size="small" :type="getStatusMeta(currentRow.status).type" round>
{{ getStatusMeta(currentRow.status).label }}
</el-tag>
<el-tag size="small" :type="getVisibilityMeta(currentRow.visibility).type" effect="plain" round>
{{ getVisibilityMeta(currentRow.visibility).label }}
</el-tag>
</div>
<div class="detail-banner__subtitle">
{{ formatGender(currentRow.gender) }} / {{ currentRow.ageRange || '年龄未填' }} / {{
currentRow.speakingStyle || '说话风格未填'
}}
</div>
</div>
</div>
<el-row :gutter="16">
<el-col :xs="24" :lg="14">
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card__title">角色摘要</div>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="性格标签">
<div class="tag-row">
<el-tag v-for="tag in getTagList(currentRow.personalityTags)" :key="tag" size="small" effect="plain"
round>
{{ tag }}
</el-tag>
<span v-if="getTagList(currentRow.personalityTags).length === 0">暂无标签</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="系统 Prompt">
{{ currentRow.systemPrompt || '暂无系统 Prompt' }}
</el-descriptions-item>
<el-descriptions-item label="开场白">
{{ currentRow.prologue || '暂无开场白' }}
</el-descriptions-item>
<el-descriptions-item label="语音配置">
<div class="detail-audio">
<span>voiceId{{ currentRow.voiceId || '-' }}</span>
<span>音频{{ currentRow.prologueAudio || '未配置' }}</span>
</div>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :xs="24" :lg="10">
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card__title">运营信息</div>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="排序权重">
{{ currentRow.sortOrder ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="热度评分">
{{ currentRow.popularityScore ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ dateFormatter(currentRow.createdAt) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ dateFormatter(currentRow.updatedAt) || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<div class="i18n-panel">
<el-tabs model-value="aiCompanionI18n">
<el-tab-pane label="国际化内容" name="aiCompanionI18n">
<AiCompanionI18nList :id="currentRow.id" />
</el-tab-pane>
</el-tabs>
</div>
</template>
2026-04-03 16:04:02 +08:00
</ContentWrap>
2026-04-03 16:32:59 +08:00
<AiCompanionForm ref="formRef" @success="getList" />
2026-02-10 15:20:30 +08:00
</template>
<script setup lang="ts">
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
2026-04-03 16:32:59 +08:00
import { isEmpty } from '@/utils/is'
import { AiCompanionApi, type AiCompanion } from '@/api/keyboard/aicompanion'
2026-02-10 15:20:30 +08:00
import AiCompanionForm from './AiCompanionForm.vue'
2026-04-03 16:04:02 +08:00
import AiCompanionI18nList from './components/AiCompanionI18nList.vue'
2026-02-10 15:20:30 +08:00
defineOptions({ name: 'KeyboardAiCompanion' })
2026-04-03 16:32:59 +08:00
type QueryParams = {
pageNo: number
pageSize: number
avatarUrl: string | undefined
coverImageUrl: string | undefined
gender: string | undefined
ageRange: string | undefined
personalityTags: string | undefined
speakingStyle: string | undefined
systemPrompt: string | undefined
status: number | undefined
visibility: number | undefined
sortOrder: number | string | undefined
popularityScore: number | string | undefined
createdAt: string | undefined
updatedAt: string | undefined
prologue: string | undefined
prologueAudio: string | undefined
voiceId: string | undefined
}
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const exportLoading = ref(false)
const showAdvancedFilters = ref(false)
const list = ref<AiCompanion[]>([])
const total = ref(0)
const checkedIds = ref<number[]>([])
const currentRow = ref<Partial<AiCompanion>>({})
const queryFormRef = ref()
const formRef = ref()
2026-02-10 15:20:30 +08:00
2026-04-03 16:32:59 +08:00
const queryParams = reactive<QueryParams>({
2026-02-10 15:20:30 +08:00
pageNo: 1,
pageSize: 10,
2026-04-03 16:04:02 +08:00
avatarUrl: undefined,
coverImageUrl: undefined,
2026-02-10 15:20:30 +08:00
gender: undefined,
2026-04-03 16:04:02 +08:00
ageRange: undefined,
personalityTags: undefined,
speakingStyle: undefined,
systemPrompt: undefined,
2026-02-10 15:20:30 +08:00
status: undefined,
visibility: undefined,
2026-04-03 16:04:02 +08:00
sortOrder: undefined,
popularityScore: undefined,
createdAt: undefined,
updatedAt: undefined,
prologue: undefined,
prologueAudio: undefined,
voiceId: undefined
2026-02-10 15:20:30 +08:00
})
2026-04-03 16:32:59 +08:00
const onlineCount = computed(() => list.value.filter((item) => Number(item.status) === 1).length)
const publicCount = computed(() => list.value.filter((item) => Number(item.visibility) === 1).length)
2026-02-10 15:20:30 +08:00
const getList = async () => {
loading.value = true
try {
const data = await AiCompanionApi.getAiCompanionPage(queryParams)
2026-04-03 16:32:59 +08:00
list.value = data.list || []
total.value = data.total || 0
syncCurrentRow()
2026-02-10 15:20:30 +08:00
} finally {
loading.value = false
}
}
2026-04-03 16:32:59 +08:00
const syncCurrentRow = () => {
if (!list.value.length) {
currentRow.value = {}
return
}
const matchedRow = list.value.find((item) => item.id === currentRow.value.id)
currentRow.value = matchedRow || list.value[0]
}
2026-02-10 15:20:30 +08:00
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
2026-04-03 16:32:59 +08:00
queryFormRef.value?.resetFields()
checkedIds.value = []
2026-02-10 15:20:30 +08:00
handleQuery()
}
const openForm = (type: string, id?: number) => {
2026-04-03 16:32:59 +08:00
formRef.value?.open(type, id)
2026-02-10 15:20:30 +08:00
}
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await AiCompanionApi.deleteAiCompanion(id)
message.success(t('common.delSuccess'))
2026-04-03 16:32:59 +08:00
checkedIds.value = checkedIds.value.filter((item) => item !== id)
if (currentRow.value.id === id) {
currentRow.value = {}
}
2026-02-10 15:20:30 +08:00
await getList()
2026-04-03 16:04:02 +08:00
} catch { }
2026-02-10 15:20:30 +08:00
}
const handleDeleteBatch = async () => {
try {
await message.delConfirm()
2026-04-03 16:32:59 +08:00
await AiCompanionApi.deleteAiCompanionList(checkedIds.value)
checkedIds.value = []
2026-02-10 15:20:30 +08:00
message.success(t('common.delSuccess'))
2026-04-03 16:32:59 +08:00
await getList()
2026-04-03 16:04:02 +08:00
} catch { }
}
const handleRowCheckboxChange = (records: AiCompanion[]) => {
2026-04-03 16:32:59 +08:00
checkedIds.value = records.map((item) => item.id)
2026-02-10 15:20:30 +08:00
}
const handleExport = async () => {
try {
await message.exportConfirm()
exportLoading.value = true
const data = await AiCompanionApi.exportAiCompanion(queryParams)
2026-04-03 16:32:59 +08:00
download.excel(data, 'AI陪聊角色.xls')
2026-02-10 15:20:30 +08:00
} catch {
} finally {
exportLoading.value = false
}
}
2026-04-03 16:32:59 +08:00
const handleCurrentChange = (row?: AiCompanion) => {
currentRow.value = row || {}
}
const getStatusMeta = (status?: number) => {
if (Number(status) === 1) {
return { label: '在线', type: 'success' as const }
}
return { label: '离线', type: 'info' as const }
}
const getVisibilityMeta = (visibility?: number) => {
if (Number(visibility) === 1) {
return { label: '公开', type: 'success' as const }
}
if (Number(visibility) === 2) {
return { label: '内测', type: 'warning' as const }
}
return { label: '隐藏', type: 'info' as const }
}
const formatGender = (gender?: string) => {
if (!gender) return '未设置性别'
const normalized = gender.toLowerCase()
if (normalized === 'male') return 'Male'
if (normalized === 'female') return 'Female'
if (normalized === 'other') return 'Other'
return gender
}
const getTagList = (tags?: AiCompanion['personalityTags']) => {
if (!tags) return []
if (Array.isArray(tags)) {
return tags.map((item) => String(item)).filter(Boolean)
}
if (typeof tags === 'string') {
return tags
.split(/[,、]/)
.map((item) => item.trim())
.filter(Boolean)
}
return []
}
const buildPreviewList = (row: AiCompanion) => {
return [row.coverImageUrl, row.avatarUrl].filter(Boolean) as string[]
2026-04-03 16:04:02 +08:00
}
2026-02-10 15:20:30 +08:00
onMounted(() => {
getList()
})
</script>
2026-04-03 16:04:02 +08:00
<style scoped>
2026-04-03 16:32:59 +08:00
.page-hero {
overflow: hidden;
2026-04-03 16:04:02 +08:00
}
2026-04-03 16:32:59 +08:00
.hero-panel {
display: flex;
gap: 24px;
justify-content: space-between;
padding: 4px;
color: #163247;
}
.hero-eyebrow {
margin-bottom: 10px;
color: #5b7a90;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero-title {
margin-bottom: 10px;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.hero-subtitle {
max-width: 640px;
color: #5f7386;
line-height: 1.7;
}
.hero-metrics {
display: grid;
flex-shrink: 0;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 12px;
}
.metric-card {
min-width: 120px;
padding: 16px;
background: linear-gradient(180deg, rgb(255 255 255 / 90%), rgb(243 248 252 / 92%));
border: 1px solid rgb(118 153 177 / 18%);
border-radius: 18px;
box-shadow: 0 12px 30px rgb(109 141 160 / 10%);
}
.metric-card.accent {
background: linear-gradient(135deg, #1c7ed6, #34a0a4);
color: #fff;
}
.metric-label {
margin-bottom: 10px;
font-size: 12px;
opacity: 0.75;
}
.metric-value {
font-size: 24px;
font-weight: 700;
}
.toolbar-header {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.section-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 700;
}
.section-desc {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 18px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 16px;
}
.advanced-panel {
padding-top: 8px;
margin-top: 4px;
border-top: 1px dashed var(--el-border-color-lighter);
2026-04-03 16:04:02 +08:00
}
2026-04-03 16:32:59 +08:00
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
padding-top: 4px;
}
.action-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.selection-indicator {
display: inline-flex;
gap: 4px;
align-items: center;
padding: 8px 14px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border-radius: 999px;
transition: all var(--el-transition-duration);
}
.selection-indicator.active {
color: #155e75;
background: #e0f2fe;
}
.companion-cell {
display: flex;
gap: 14px;
align-items: center;
}
.companion-main {
min-width: 0;
flex: 1;
}
.companion-head {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.companion-id {
color: var(--el-text-color-primary);
font-weight: 700;
2026-04-03 16:04:02 +08:00
}
2026-04-03 16:32:59 +08:00
.companion-meta {
2026-04-03 16:04:02 +08:00
display: flex;
2026-04-03 16:32:59 +08:00
gap: 8px;
margin-top: 3px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.avatar-fallback {
display: inline-flex;
2026-04-03 16:04:02 +08:00
align-items: center;
justify-content: center;
2026-04-03 16:32:59 +08:00
width: 42px;
height: 42px;
color: #155e75;
font-size: 13px;
font-weight: 700;
background: linear-gradient(135deg, #d7f3ff, #eefaf5);
border-radius: 50%;
}
.avatar-fallback--large {
width: 56px;
height: 56px;
}
.cover-image {
flex-shrink: 0;
width: 88px;
height: 56px;
overflow: hidden;
border-radius: 14px;
}
.cover-image--empty {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
font-size: 12px;
background: var(--el-fill-color-light);
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.muted-text {
color: var(--el-text-color-secondary);
2026-04-03 16:04:02 +08:00
font-size: 12px;
}
2026-04-03 16:32:59 +08:00
.stack-text {
display: flex;
flex-direction: column;
gap: 6px;
}
.stack-title {
color: var(--el-text-color-primary);
font-weight: 600;
}
.stack-desc {
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.status-stack,
.score-stack {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.score-highlight {
color: #0f766e;
font-weight: 600;
}
.mono-text {
font-family: Consolas, Monaco, 'Courier New', monospace;
}
.detail-banner {
display: flex;
gap: 14px;
align-items: center;
padding: 18px 20px;
margin-bottom: 16px;
background: linear-gradient(135deg, #f7fbff, #f7fcfa);
border: 1px solid var(--el-border-color-lighter);
border-radius: 18px;
}
.detail-banner__content {
min-width: 0;
flex: 1;
}
.detail-banner__title {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 8px;
color: var(--el-text-color-primary);
font-size: 18px;
font-weight: 700;
}
.detail-banner__subtitle {
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.detail-card {
height: 100%;
margin-bottom: 16px;
}
.detail-card__title {
color: var(--el-text-color-primary);
font-weight: 700;
}
.detail-audio {
display: flex;
flex-direction: column;
gap: 6px;
}
.i18n-panel {
margin-top: 8px;
}
:deep(.el-table .cell) {
line-height: 1.5;
}
:deep(.el-table__row.current-row > td.el-table__cell) {
background: #f0f9ff !important;
}
:deep(.el-image__error),
2026-04-03 16:04:02 +08:00
:deep(.el-image__placeholder) {
2026-04-03 16:32:59 +08:00
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
@media (max-width: 992px) {
.hero-panel,
.toolbar-header,
.toolbar-actions {
flex-direction: column;
align-items: stretch;
}
.hero-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.hero-title {
font-size: 24px;
}
.hero-metrics {
grid-template-columns: 1fr;
}
.companion-cell,
.detail-banner {
align-items: flex-start;
}
2026-04-03 16:04:02 +08:00
}
2026-04-03 16:32:59 +08:00
</style>