yumi-admin/apps/src/views/props/components/resource-config-modal.vue
2026-05-17 13:46:34 +08:00

834 lines
20 KiB
Vue

<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import {
Button,
Form,
FormItem,
Image,
Input,
InputNumber,
message,
Select,
Switch,
} from 'antdv-next';
import {
getAccessImgUrl,
OSS_FILE_BUCKETS,
simpleUploadFile,
} from '#/api/legacy/oss';
import {
addPropsSource,
updatePropsSource,
} from '#/api/legacy/props';
import {
BADGE_DISPLAY_TYPE_OPTIONS,
NOBLE_VIP_OPTIONS,
PROPS_RESOURCE_CONFIG_TYPES,
} from '../shared';
const props = defineProps<{
open: boolean;
record: null | Record<string, any>;
sysOriginOptions: Array<{ label: string; value: string }>;
}>();
const emit = defineEmits<{
close: [];
success: [];
}>();
const saving = ref(false);
const coverUploading = ref(false);
const sourceUploading = ref(false);
const expandUploading = ref(false);
const notSelectUploading = ref(false);
const coverInputRef = ref<HTMLInputElement>();
const sourceInputRef = ref<HTMLInputElement>();
const expandInputRef = ref<HTMLInputElement>();
const notSelectInputRef = ref<HTMLInputElement>();
const form = reactive<Record<string, any>>({
adminFree: false,
amount: '',
badgeDisplayType: 'SHORT',
code: '',
cover: '',
expand: '',
id: '',
name: '',
notSelectUrl: '',
sourceUrl: '',
sysOrigin: '',
type: '',
});
const isUpdate = computed(() => Boolean(form.id));
const isImageSourceType = computed(() =>
['CHAT_BUBBLE', 'LAYOUT', 'THEME'].includes(form.type),
);
const isAmountRequired = computed(
() => !['CUSTOMIZE', 'FRAGMENTS'].includes(form.type),
);
const showSourceUpload = computed(
() => !['CUSTOMIZE', 'FRAGMENTS'].includes(form.type),
);
const isSourceRequired = computed(
() => !['BADGE', 'CUSTOMIZE', 'FRAGMENTS'].includes(form.type),
);
const showExpandUpload = computed(() => form.type === 'CHAT_BUBBLE');
const sourceUploadLabel = computed(() => {
if (form.type === 'BADGE') {
return '动效资源(选填)';
}
if (showExpandUpload.value) {
return 'iOS资源';
}
return isSourceRequired.value ? '资源' : '资源(选填)';
});
const title = computed(() => (isUpdate.value ? '修改资源' : '新增资源'));
const propsTypeOptions = PROPS_RESOURCE_CONFIG_TYPES.map((item) => ({
label: item.name,
value: item.value as any,
}));
const nobleVipNameOptions = NOBLE_VIP_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
const badgeDisplayTypeOptions = BADGE_DISPLAY_TYPE_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
function parseBadgeExpand(value: unknown) {
if (!value || typeof value !== 'string') {
return {};
}
try {
return JSON.parse(value) as Record<string, any>;
} catch {
return {};
}
}
function applyBadgeExpand(value: unknown) {
const expand = parseBadgeExpand(value);
form.badgeDisplayType = normalizeBadgeDisplayType(
expand.badgeDisplayType || expand.displayType,
expand.badgeType,
);
form.notSelectUrl = expand.notSelectUrl ?? '';
}
function buildBadgeExpand() {
const expand: Record<string, any> = {
badgeDisplayType: normalizeBadgeDisplayType(form.badgeDisplayType),
};
if (String(form.notSelectUrl || '').trim()) {
expand.notSelectUrl = String(form.notSelectUrl).trim();
}
return JSON.stringify(expand);
}
function normalizeBadgeDisplayType(value: unknown, badgeType?: unknown) {
const displayType = String(value || '').trim().toUpperCase();
if (displayType === 'LONG' || displayType === 'SHORT') {
return displayType;
}
return String(badgeType || '').trim().toUpperCase() === 'VIP' ? 'LONG' : 'SHORT';
}
function resetForm() {
form.adminFree = false;
form.amount = '';
form.badgeDisplayType = 'SHORT';
form.code = '';
form.cover = '';
form.expand = '';
form.id = '';
form.name = '';
form.notSelectUrl = '';
form.sourceUrl = '';
form.sysOrigin = props.sysOriginOptions[0]?.value ?? 'LIKEI';
form.type = PROPS_RESOURCE_CONFIG_TYPES[0]?.value ?? '';
}
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
const record = props.record;
if (!record) {
resetForm();
return;
}
form.adminFree = Boolean(record.adminFree);
form.amount = record.amount ?? '';
form.code = record.code ?? '';
form.cover = record.cover ?? '';
form.expand = record.expand ?? '';
form.id = record.id ?? '';
form.name = record.name ?? '';
form.sourceUrl = record.sourceUrl ?? '';
form.sysOrigin = record.sysOrigin || props.sysOriginOptions[0]?.value || 'LIKEI';
form.type = record.type ?? '';
if (form.type === 'BADGE') {
applyBadgeExpand(record.expand);
} else {
applyBadgeExpand('');
}
},
{ immediate: true },
);
watch(
() => form.type,
(value) => {
if (!value) {
return;
}
if (value === 'NOBLE_VIP' && !NOBLE_VIP_OPTIONS.some((item) => item.value === form.name)) {
form.name = '';
}
if (['CUSTOMIZE', 'FRAGMENTS'].includes(value)) {
form.amount = 0;
form.sourceUrl = '';
form.expand = '';
}
if (value === 'BADGE' && !form.badgeDisplayType) {
form.badgeDisplayType = 'SHORT';
}
if (!['BADGE', 'CHAT_BUBBLE'].includes(value)) {
form.expand = '';
}
},
);
function openCoverPicker() {
coverInputRef.value?.click();
}
function openSourcePicker() {
sourceInputRef.value?.click();
}
function openExpandPicker() {
expandInputRef.value?.click();
}
function openNotSelectPicker() {
notSelectInputRef.value?.click();
}
function handleCoverInput(event: Event) {
void uploadFile(event, 'cover', coverUploading, true);
}
function handleSourceInput(event: Event) {
void uploadFile(event, 'sourceUrl', sourceUploading, isImageSourceType.value);
}
function handleExpandInput(event: Event) {
void uploadFile(event, 'expand', expandUploading, true);
}
function handleNotSelectInput(event: Event) {
void uploadFile(event, 'notSelectUrl', notSelectUploading, true);
}
function clearUpload(targetKey: 'cover' | 'expand' | 'notSelectUrl' | 'sourceUrl') {
form[targetKey] = '';
}
function getAssetName(value: unknown) {
const text = String(value || '').trim();
if (!text) {
return '';
}
return decodeURIComponent(text.split('/').pop() || text);
}
async function uploadFile(
event: Event,
targetKey: 'cover' | 'expand' | 'notSelectUrl' | 'sourceUrl',
loadingRef: typeof coverUploading,
imageBucket = false,
) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
target.value = '';
if (!file) {
return;
}
loadingRef.value = true;
try {
const result = await simpleUploadFile(
file,
imageBucket ? OSS_FILE_BUCKETS.svgaCover : OSS_FILE_BUCKETS.svgasource,
);
form[targetKey] = getAccessImgUrl(result.name);
message.success('上传成功');
} finally {
loadingRef.value = false;
}
}
function validateForm() {
if (!form.sysOrigin) {
message.warning('请选择系统');
return false;
}
if (!form.type) {
message.warning('请选择类型');
return false;
}
if (form.type === 'BADGE' && !form.badgeDisplayType) {
message.warning('请选择徽章展示形态');
return false;
}
if (!String(form.cover || '').trim()) {
message.warning('请上传封面');
return false;
}
if (isSourceRequired.value && !String(form.sourceUrl || '').trim()) {
message.warning('请上传资源');
return false;
}
if (showExpandUpload.value && !String(form.expand || '').trim()) {
message.warning('请上传 Android 资源');
return false;
}
if (!String(form.name || '').trim()) {
message.warning(form.type === 'NOBLE_VIP' ? '请选择贵族类型' : '请输入名称');
return false;
}
if (!isUpdate.value && !String(form.code || '').trim()) {
message.warning('请输入编码');
return false;
}
if (
isAmountRequired.value &&
(form.amount === '' ||
form.amount === null ||
form.amount === undefined ||
Number.isNaN(Number(form.amount)))
) {
message.warning('请输入底价');
return false;
}
return true;
}
async function handleSubmit() {
if (!validateForm()) {
return;
}
saving.value = true;
try {
const payload = {
adminFree: form.adminFree,
amount: isAmountRequired.value ? Number(form.amount) : 0,
code: form.code,
cover: form.cover,
expand: form.type === 'BADGE' ? buildBadgeExpand() : form.expand,
id: form.id,
name: form.name,
sourceUrl: form.sourceUrl,
sysOrigin: form.sysOrigin,
type: form.type,
};
await (isUpdate.value ? updatePropsSource(payload) : addPropsSource(payload));
message.success('保存成功');
emit('success');
emit('close');
} finally {
saving.value = false;
}
}
</script>
<template>
<section v-if="open" class="quick-editor">
<input
ref="coverInputRef"
accept="image/*"
class="hidden-input"
type="file"
@change="handleCoverInput"
/>
<input
ref="notSelectInputRef"
accept="image/*"
class="hidden-input"
type="file"
@change="handleNotSelectInput"
/>
<input
ref="sourceInputRef"
:accept="isImageSourceType ? 'image/*' : '.svga,.pag,.mp4'"
class="hidden-input"
type="file"
@change="handleSourceInput"
/>
<input
ref="expandInputRef"
accept="image/*"
class="hidden-input"
type="file"
@change="handleExpandInput"
/>
<div class="quick-editor__bar">
<div class="quick-editor__title">{{ title }}</div>
<div class="quick-editor__actions">
<Button @click="emit('close')">取消</Button>
<Button :loading="saving" type="primary" @click="handleSubmit">
保存
</Button>
</div>
</div>
<Form class="resource-form" layout="vertical">
<section class="compact-section">
<div class="section-title">基础配置</div>
<div class="form-grid">
<FormItem label="系统">
<SysOriginSelect
v-model:value="form.sysOrigin"
:options="sysOriginOptions"
/>
</FormItem>
<FormItem label="类型">
<Select
v-model:value="form.type"
option-label-prop="label"
:options="propsTypeOptions"
/>
</FormItem>
<FormItem v-if="form.type === 'BADGE'" label="展示形态">
<Select
v-model:value="form.badgeDisplayType"
option-label-prop="label"
:options="badgeDisplayTypeOptions"
/>
</FormItem>
<FormItem v-if="isAmountRequired" label="底价">
<InputNumber
v-model:value="form.amount"
:min="0"
placeholder="请输入底价"
style="width: 100%"
/>
</FormItem>
<FormItem label="Admin Free">
<div class="switch-line">
<Switch
v-model:checked="form.adminFree"
checked-children=""
un-checked-children=""
/>
</div>
</FormItem>
<FormItem class="form-grid__wide" label="名称">
<Select
v-if="form.type === 'NOBLE_VIP'"
v-model:value="form.name"
option-label-prop="label"
:options="nobleVipNameOptions"
/>
<Input v-else v-model:value="form.name" />
</FormItem>
<FormItem v-if="!isUpdate" label="编码">
<Input v-model:value="form.code" placeholder="添加后不可修改" />
</FormItem>
</div>
</section>
<section class="compact-section">
<div class="section-title">资源文件</div>
<div class="asset-grid">
<div class="asset-card">
<div class="asset-preview">
<Image
v-if="form.cover"
class="asset-preview__image"
:src="form.cover"
/>
<div v-else class="asset-empty">未上传</div>
</div>
<div class="asset-detail">
<div class="asset-card__head">
<span>封面</span>
<span
class="asset-status"
:class="{ 'asset-status--ready': form.cover }"
>
{{ form.cover ? '已上传' : '必填' }}
</span>
</div>
<div class="asset-meta">{{ getAssetName(form.cover) || '-' }}</div>
</div>
<div class="asset-actions">
<Button :loading="coverUploading" @click="openCoverPicker">
{{ form.cover ? '替换' : '上传' }}
</Button>
<Button
v-if="form.cover"
danger
type="text"
@click="clearUpload('cover')"
>
删除
</Button>
</div>
</div>
<div v-if="showSourceUpload" class="asset-card">
<div class="asset-preview">
<Image
v-if="form.sourceUrl && isImageSourceType"
class="asset-preview__image"
:src="form.sourceUrl"
/>
<div v-else-if="form.sourceUrl" class="asset-file">
{{ getAssetName(form.sourceUrl) }}
</div>
<div v-else class="asset-empty">未上传</div>
</div>
<div class="asset-detail">
<div class="asset-card__head">
<span>{{ sourceUploadLabel }}</span>
<span
class="asset-status"
:class="{ 'asset-status--ready': form.sourceUrl }"
>
{{ form.sourceUrl ? '已上传' : isSourceRequired ? '必填' : '选填' }}
</span>
</div>
<div class="asset-meta">
{{ getAssetName(form.sourceUrl) || '-' }}
</div>
</div>
<div class="asset-actions">
<Button :loading="sourceUploading" @click="openSourcePicker">
{{ form.sourceUrl ? '替换' : '上传' }}
</Button>
<Button
v-if="form.sourceUrl"
danger
type="text"
@click="clearUpload('sourceUrl')"
>
删除
</Button>
</div>
</div>
<div v-if="showExpandUpload" class="asset-card">
<div class="asset-preview">
<Image
v-if="form.expand"
class="asset-preview__image"
:src="form.expand"
/>
<div v-else class="asset-empty">未上传</div>
</div>
<div class="asset-detail">
<div class="asset-card__head">
<span>Android资源</span>
<span
class="asset-status"
:class="{ 'asset-status--ready': form.expand }"
>
{{ form.expand ? '已上传' : '必填' }}
</span>
</div>
<div class="asset-meta">{{ getAssetName(form.expand) || '-' }}</div>
</div>
<div class="asset-actions">
<Button :loading="expandUploading" @click="openExpandPicker">
{{ form.expand ? '替换' : '上传' }}
</Button>
<Button
v-if="form.expand"
danger
type="text"
@click="clearUpload('expand')"
>
删除
</Button>
</div>
</div>
<div v-if="form.type === 'BADGE'" class="asset-card">
<div class="asset-preview">
<Image
v-if="form.notSelectUrl"
class="asset-preview__image"
:src="form.notSelectUrl"
/>
<div v-else class="asset-empty">未上传</div>
</div>
<div class="asset-detail">
<div class="asset-card__head">
<span>未选中图</span>
<span
class="asset-status"
:class="{ 'asset-status--ready': form.notSelectUrl }"
>
{{ form.notSelectUrl ? '已上传' : '选填' }}
</span>
</div>
<div class="asset-meta">
{{ getAssetName(form.notSelectUrl) || '-' }}
</div>
</div>
<div class="asset-actions">
<Button :loading="notSelectUploading" @click="openNotSelectPicker">
{{ form.notSelectUrl ? '替换' : '上传' }}
</Button>
<Button
v-if="form.notSelectUrl"
danger
type="text"
@click="clearUpload('notSelectUrl')"
>
删除
</Button>
</div>
</div>
</div>
</section>
</Form>
</section>
</template>
<style scoped>
.hidden-input {
display: none;
}
.quick-editor {
background: #fff;
border: 1px solid #d8dee8;
border-radius: 8px;
box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
margin-bottom: 8px;
padding: 12px;
}
.quick-editor__bar {
align-items: center;
border-bottom: 1px solid #eef0f3;
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 10px;
}
.quick-editor__title {
color: #111827;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.quick-editor__actions {
align-items: center;
display: flex;
flex: 0 0 auto;
gap: 8px;
}
.section-title {
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 20px;
margin-bottom: 10px;
}
.resource-form {
display: grid;
gap: 12px;
}
.resource-form :deep(.ant-form-item) {
margin-bottom: 0;
}
.resource-form :deep(.ant-form-item-label) {
padding-bottom: 3px;
}
.resource-form :deep(.ant-form-item-label > label) {
color: #475467;
font-size: 13px;
height: 18px;
}
.compact-section {
border-bottom: 1px solid #eef0f3;
padding-bottom: 12px;
}
.compact-section:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.form-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(170px, 1fr));
}
.form-grid__wide {
grid-column: span 2;
}
.switch-line {
align-items: center;
display: flex;
gap: 8px;
min-height: 32px;
}
.asset-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(320px, 1fr));
}
.asset-card {
align-items: center;
background: #fbfdff;
border: 1px solid #e5e7eb;
border-radius: 6px;
display: grid;
gap: 12px;
grid-template-columns: 96px minmax(0, 1fr) auto;
min-width: 0;
padding: 12px;
}
.asset-card__head,
.asset-actions {
align-items: center;
display: flex;
}
.asset-card__head {
color: #344054;
gap: 8px;
font-weight: 600;
min-width: 0;
}
.asset-card__head span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-status {
background: #f3f4f6;
border-radius: 999px;
color: #667085;
flex: 0 0 auto;
font-size: 12px;
font-weight: 500;
line-height: 20px;
padding: 0 8px;
}
.asset-status--ready {
background: #e8f3ff;
color: #1677ff;
}
.asset-preview {
align-items: center;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 6px;
display: flex;
height: 76px;
justify-content: center;
min-height: 0;
overflow: hidden;
padding: 6px;
}
.asset-preview__image {
height: 64px;
object-fit: contain;
width: 100%;
}
.asset-empty {
color: #98a2b3;
font-size: 13px;
}
.asset-file {
color: #344054;
font-size: 12px;
line-height: 1.35;
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.asset-meta {
color: #667085;
font-size: 12px;
line-height: 18px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-actions {
gap: 6px;
justify-content: flex-end;
}
@media (max-width: 1120px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid__wide {
grid-column: span 2;
}
}
@media (max-width: 720px) {
.asset-grid,
.form-grid {
grid-template-columns: 1fr;
}
.form-grid__wide {
grid-column: span 1;
}
.asset-card {
grid-template-columns: 80px minmax(0, 1fr);
}
.asset-actions {
grid-column: 1 / -1;
justify-content: flex-start;
}
}
</style>