官网开发中 0731

This commit is contained in:
eafonyang
2026-07-31 19:02:00 +08:00
parent fda4fa8627
commit eeccdb079c
36 changed files with 1944 additions and 105 deletions
@@ -13,6 +13,7 @@ export function fetchUploadMedia(file: File, onProgress?: (percent: number) => v
url: '/www/media/upload',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e: { loaded: number; total?: number }) => {
if (onProgress && e.total) {
onProgress(Math.round((e.loaded / e.total) * 100));
@@ -71,7 +71,7 @@ const columns: DataTableColumns<Api.Www.FormField> = [
width: 60,
render(row) {
return h(NSwitch, {
value: row.is_required,
value: Boolean(row.is_required),
onUpdateValue: (val: boolean) => handleToggleRequired(row, val)
});
}
@@ -130,7 +130,7 @@ function handleEditField(row: Api.Www.FormField) {
fieldFormData.name_zh = row.name_zh;
fieldFormData.name_en = row.name_en;
fieldFormData.field_type = row.field_type as 'text' | 'email' | 'phone' | 'textarea' | 'select';
fieldFormData.is_required = row.is_required;
fieldFormData.is_required = Boolean(row.is_required);
fieldFormData.placeholder_zh = row.placeholder_zh;
fieldFormData.placeholder_en = row.placeholder_en;
fieldFormData.options_json = row.options_json || '';
+219 -15
View File
@@ -5,6 +5,7 @@ import {
fetchDeleteMediaItem,
fetchGetMedia,
fetchGetMediaTags,
fetchUpdateMediaItem,
fetchUploadMedia
} from '@/service/api';
@@ -53,6 +54,114 @@ function categoryLabel(val: string) {
return categoryOptions.find(o => o.value === val)?.label || val;
}
// ===== 预览 =====
const previewVisible = ref(false);
const previewRow = ref<Api.Www.MediaItem | null>(null);
// 预览类型:图片 / 视频 / PDF 可在线预览,其余(Office 等)仅提供下载
type PreviewKind = 'image' | 'video' | 'pdf' | 'unsupported';
const previewKind = computed<PreviewKind>(() => {
const row = previewRow.value;
if (!row) return 'unsupported';
if (row.file_type === 'image') return 'image';
if (row.file_type === 'video') return 'video';
const ext = row.file_path.split('.').pop()?.toLowerCase() || '';
if (ext === 'pdf' || row.mime_type === 'application/pdf') return 'pdf';
return 'unsupported';
});
// 相对路径(用于页面内 img/video/iframe 加载,/uploads 已由 vite/nginx 代理)
const previewUrl = computed(() => previewRow.value?.file_path || '');
// 完整可访问 URL(用于复制链接)
const previewFullUrl = computed(() => (previewRow.value ? window.location.origin + previewRow.value.file_path : ''));
function handlePreview(row: Api.Www.MediaItem) {
previewRow.value = row;
previewVisible.value = true;
}
// ===== 复制链接 =====
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 非安全上下文(http)下 clipboard API 不可用,降级为 execCommand
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
}
}
async function handleCopyLink(row: Api.Www.MediaItem) {
const url = window.location.origin + row.file_path;
const ok = await copyText(url);
if (ok) window.$message?.success('链接已复制到剪贴板');
else window.$message?.error('复制失败,请手动复制');
}
async function copyPreviewLink() {
if (!previewRow.value) return;
await handleCopyLink(previewRow.value);
}
// ===== 编辑(重命名 + 分类 + 标签)=====
const editVisible = ref(false);
const editLoading = ref(false);
const editForm = reactive({
id: 0,
filename: '',
category: 'other',
tags: [] as string[]
});
const tagSelectOptions = computed(() => tagOptions.value.map(t => ({ label: t.tag_name, value: t.tag_name })));
function handleEdit(row: Api.Www.MediaItem) {
editForm.id = row.id;
editForm.filename = row.filename;
editForm.category = row.category;
editForm.tags = [...(row.tags || [])];
editVisible.value = true;
}
async function handleEditSave() {
if (!editForm.filename?.trim()) {
window.$message?.warning('文件名不能为空');
return;
}
editLoading.value = true;
const { error } = await fetchUpdateMediaItem(editForm.id, {
filename: editForm.filename.trim(),
category: editForm.category,
tags: editForm.tags
});
editLoading.value = false;
if (!error) {
window.$message?.success('保存成功');
editVisible.value = false;
loadData();
loadTags(); // 可能新建了标签,刷新下拉选项
}
}
// ===== 行点击自动复制链接 =====
function handleRowClick(row: Api.Www.MediaItem) {
handleCopyLink(row);
}
const rowProps = (row: Api.Www.MediaItem) => ({
style: 'cursor: pointer;',
onClick: () => handleRowClick(row)
});
// ===== 表格列 =====
const columns = computed<DataTableColumns<Api.Www.MediaItem>>(() => [
{ title: 'ID', key: 'id', width: 60 },
{
@@ -60,14 +169,15 @@ const columns = computed<DataTableColumns<Api.Www.MediaItem>>(() => [
key: 'thumbnail_url',
width: 80,
render(row) {
if (row.file_type === 'image' && (row.thumbnail_url || row.file_path)) {
return h('img', {
src: row.thumbnail_url || row.file_path,
alt: row.filename,
style: 'width: 48px; height: 48px; object-fit: cover; border-radius: 4px;'
});
}
return h(NTag, { size: 'small' }, { default: () => row.file_type });
const content
= row.file_type === 'image' && (row.thumbnail_url || row.file_path)
? h('img', {
src: row.thumbnail_url || row.file_path,
alt: row.filename,
style: 'width: 48px; height: 48px; object-fit: cover; border-radius: 4px;'
})
: h(NTag, { size: 'small' }, { default: () => row.file_type });
return h('div', { style: 'cursor: pointer;', onClick: (e: Event) => { e.stopPropagation(); handlePreview(row); } }, [content]);
}
},
{ title: '文件名', key: 'filename', ellipsis: { tooltip: true } },
@@ -95,14 +205,15 @@ const columns = computed<DataTableColumns<Api.Www.MediaItem>>(() => [
{
title: '操作',
key: 'actions',
width: 100,
width: 130,
fixed: 'right',
render(row) {
return h(NButton, {
size: 'small',
type: 'error',
onClick: () => handleDelete(row)
}, { default: () => '删除' });
return h(NSpace, { size: 8, onClick: (e: Event) => e.stopPropagation() }, {
default: () => [
h(NButton, { type: 'warning', size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
h(NButton, { type: 'error', size: 'small', onClick: () => handleDelete(row) }, { default: () => '删除' })
]
});
}
}
]);
@@ -191,6 +302,7 @@ onMounted(() => {
</script>
<template>
<div>
<NSpace vertical :size="16">
<NCard :bordered="false" class="card-wrapper" size="small">
<NForm inline :model="searchForm" label-placement="left">
@@ -228,7 +340,7 @@ onMounted(() => {
</NUpload>
</template>
<NDataTable :loading="loading" :columns="columns" :data="tableData" :bordered="false" size="small" />
<NDataTable :loading="loading" :columns="columns" :data="tableData" :bordered="false" size="small" :scroll-x="900" :row-props="rowProps" />
<div class="mt-16px flex justify-end">
<NPagination
@@ -244,4 +356,96 @@ onMounted(() => {
</div>
</NCard>
</NSpace>
<!-- 预览弹窗图片大图 / 视频播放 / PDF 内嵌 / Office 等仅下载 -->
<NModal
v-model:show="previewVisible"
preset="card"
:title="previewRow?.filename || '素材预览'"
style="width: 780px; max-width: 92vw;"
:bordered="false"
>
<div v-if="previewRow" class="flex flex-col gap-12px">
<!-- 图片 -->
<div v-if="previewKind === 'image'" class="flex-center">
<img
:src="previewUrl"
:alt="previewRow.filename"
style="max-width: 100%; max-height: 68vh; object-fit: contain; border-radius: 6px;"
>
</div>
<!-- 视频 -->
<div v-else-if="previewKind === 'video'" class="flex-center">
<video :src="previewUrl" controls autoplay style="max-width: 100%; max-height: 68vh; border-radius: 6px;" />
</div>
<!-- PDF -->
<iframe v-else-if="previewKind === 'pdf'" :src="previewUrl" style="width: 100%; height: 70vh; border: none; border-radius: 6px;" />
<!-- 暂不支持在线预览Office -->
<div v-else class="flex-col-center py-40px">
<SvgIcon icon="mdi:file-document-outline" class="text-48px text-gray-300" />
<p class="mt-12px text-14px text-gray-500">
该文件类型暂不支持在线预览
</p>
<p class="mt-4px text-12px text-gray-400">
{{ previewRow.mime_type || '未知类型' }} · {{ formatSize(previewRow.file_size) }}
</p>
<NButton class="mt-16px" tag="a" :href="previewUrl" download>
<template #icon><SvgIcon icon="mdi:download" /></template>
下载文件
</NButton>
</div>
<!-- 文件信息 + 操作 -->
<div class="flex items-center justify-between border-t border-gray-100 pt-12px">
<span class="truncate text-12px text-gray-400">{{ previewRow.filename }} · {{ formatSize(previewRow.file_size) }}</span>
<NSpace>
<NButton size="small" @click="copyPreviewLink">
<template #icon><SvgIcon icon="mdi:content-copy" /></template>
复制链接
</NButton>
<NButton size="small" tag="a" :href="previewUrl" download>
<template #icon><SvgIcon icon="mdi:download" /></template>
下载
</NButton>
</NSpace>
</div>
</div>
</NModal>
<!-- 编辑弹窗重命名 + 分类 + 多标签 -->
<NModal v-model:show="editVisible" preset="card" title="编辑素材" style="width: 520px; max-width: 92vw;" :bordered="false">
<NForm :model="editForm" label-placement="left" label-width="80">
<NFormItem label="文件名">
<NInput v-model:value="editForm.filename" placeholder="请输入有意义的文件名" />
</NFormItem>
<NFormItem label="分类">
<NSelect v-model:value="editForm.category" :options="categoryOptions" />
</NFormItem>
<NFormItem label="标签">
<NSelect
v-model:value="editForm.tags"
:options="tagSelectOptions"
multiple
filterable
tag
clearable
placeholder="选择已有标签或输入新标签后回车"
/>
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="editVisible = false">
取消
</NButton>
<NButton type="primary" :loading="editLoading" @click="handleEditSave">
保存
</NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
@@ -55,7 +55,7 @@ const columns: DataTableColumns<Api.Www.NavItem> = [
width: 80,
render(row) {
return h(NSwitch, {
value: row.is_visible,
value: Boolean(row.is_visible),
onUpdateValue: (val: boolean) => handleToggleVisible(row, val)
});
}
@@ -111,11 +111,11 @@ function handleEdit(row: Api.Www.NavItem) {
formData.name_zh = row.name_zh;
formData.name_en = row.name_en;
formData.link = row.link;
formData.is_visible = row.is_visible;
formData.is_visible = Boolean(row.is_visible);
formData.display_mode = row.display_mode;
formData.icon_name = row.icon_name;
formData.image_url = row.image_url;
formData.open_new_tab = row.open_new_tab;
formData.open_new_tab = Boolean(row.open_new_tab);
dialogVisible.value = true;
}
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue';
import { NButton, NSpace, NSwitch, NTag, type DataTableColumns, type FormInst } from 'naive-ui';
import { computed, h, onMounted, reactive, ref } from 'vue';
import { NButton, NSpace, NSwitch, NTag, type DataTableColumns, type FormInst, type SelectOption, type SelectRenderOption } from 'naive-ui';
import {
fetchCreateSection,
fetchDeleteSection,
fetchGetSections,
fetchSortSections,
fetchUpdateSection
} from '@/service/api';
@@ -17,19 +18,43 @@ const dialogTitle = ref('');
const formRef = ref<FormInst | null>(null);
const tableData = ref<Api.Www.SectionBlock[]>([]);
const layoutOptions = [
{ label: 'Hero Split', value: 'hero_split' },
{ label: 'Full Width Banner', value: 'full_banner' },
{ label: 'Feature Grid', value: 'feature_grid' },
{ label: 'Split Content', value: 'split_content' },
{ label: 'Gallery', value: 'gallery' },
{ label: 'Stats', value: 'stats' },
{ label: 'Video Showcase', value: 'video_showcase' },
{ label: 'Scroll Narrative', value: 'scroll_narrative' },
{ label: 'Quote', value: 'quote' },
{ label: 'Spec Table', value: 'spec_table' }
type LayoutOption = SelectOption & { desc: string };
const layoutOptions: LayoutOption[] = [
{ label: '主视觉分屏', value: 'hero_split', desc: '左文案右图片,首页首屏大图' },
{ label: '通栏大横幅', value: 'full_banner', desc: '全宽背景图,标题文案居中' },
{ label: '特性网格', value: 'feature_grid', desc: '多列卡片,展示产品特性卖点' },
{ label: '图文分栏', value: 'split_content', desc: '图片与文案左右并排介绍' },
{ label: '图片画廊', value: 'gallery', desc: '多张图片网格展示' },
{ label: '数据统计', value: 'stats', desc: '大数字展示关键指标数据' },
{ label: '视频展示', value: 'video_showcase', desc: '以视频为主的视觉区块' },
{ label: '滚动叙事', value: 'scroll_narrative', desc: '随滚动逐步展开的叙事内容' },
{ label: '引言评价', value: 'quote', desc: '引用语 / 用户评价展示' },
{ label: '参数规格表', value: 'spec_table', desc: '产品技术参数规格表格' }
];
function layoutLabel(value: string) {
return (layoutOptions.find(o => o.value === value)?.label as string) || value;
}
function renderLayoutLabel(option: SelectOption, _selected: boolean) {
const opt = option as LayoutOption;
// 始终渲染 label + desc(收起态的 desc 由 CSS 隐藏,见底部 <style>
return h('div', { class: 'flex flex-col py-2px' }, [
h('span', { class: 'text-14px' }, opt.label as string),
h('span', { class: 'text-12px opacity-60 layout-desc' }, opt.desc)
]);
}
const renderLayoutOption: SelectRenderOption = ({ node, option }) => {
const opt = option as LayoutOption;
return h('div', {
style: opt.value === layoutOptions[layoutOptions.length - 1].value
? ''
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06);'
}, [node]);
};
const themeOptions = [
{ label: '浅色', value: 'light' },
{ label: '深色', value: 'dark' }
@@ -63,9 +88,29 @@ const formData = reactive({
is_visible: true
});
const columns: DataTableColumns<Api.Www.SectionBlock> = [
{ title: '排序', key: 'sort_order', width: 60 },
{ title: '布局', key: 'layout', width: 130 },
const columns = computed((): DataTableColumns<Api.Www.SectionBlock> => [
{
title: '排序',
key: 'sort_order',
width: 110,
render(row) {
const idx = tableData.value.findIndex(item => item.id === row.id);
const isFirst = idx === 0;
const isLast = idx === tableData.value.length - 1;
return h('div', { class: 'flex-y-center gap-4px' }, [
h('span', { class: 'text-12px text-gray-400 w-20px text-center' }, { default: () => String(row.sort_order) }),
h(NButton, {
text: true, size: 'tiny', type: 'primary', disabled: isFirst,
onClick: () => handleMove(idx, -1)
}, { default: () => '↑' }),
h(NButton, {
text: true, size: 'tiny', type: 'primary', disabled: isLast,
onClick: () => handleMove(idx, 1)
}, { default: () => '↓' })
]);
}
},
{ title: '布局', key: 'layout', width: 130, render: row => layoutLabel(row.layout) },
{
title: '主题',
key: 'theme',
@@ -84,7 +129,7 @@ const columns: DataTableColumns<Api.Www.SectionBlock> = [
width: 80,
render(row) {
return h(NSwitch, {
value: row.is_visible,
value: Boolean(row.is_visible),
onUpdateValue: (val: boolean) => handleToggleVisible(row, val)
});
}
@@ -101,7 +146,22 @@ const columns: DataTableColumns<Api.Www.SectionBlock> = [
]);
}
}
];
]);
async function handleMove(index: number, direction: -1 | 1) {
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= tableData.value.length) return;
const newData = [...tableData.value];
[newData[index], newData[swapIndex]] = [newData[swapIndex], newData[index]];
const sortPayload = newData.map((item, i) => ({ id: item.id, sort_order: i }));
const { error } = await fetchSortSections(sortPayload);
if (!error) {
window.$message?.success('排序已更新');
loadData();
}
}
async function loadData() {
loading.value = true;
@@ -149,8 +209,8 @@ function handleEdit(row: Api.Www.SectionBlock) {
formData.theme = row.theme;
formData.bg_type = row.bg_type;
formData.bg_value = row.bg_value;
formData.overlay_enabled = row.overlay_enabled;
formData.overlay_opacity = row.overlay_opacity;
formData.overlay_enabled = Boolean(row.overlay_enabled);
formData.overlay_opacity = Number(row.overlay_opacity);
formData.overline_zh = row.overline_zh;
formData.overline_en = row.overline_en;
formData.title_zh = row.title_zh;
@@ -168,7 +228,7 @@ function handleEdit(row: Api.Www.SectionBlock) {
formData.media_url_zh = row.media_url_zh;
formData.media_url_en = row.media_url_en;
formData.video_url = row.video_url;
formData.is_visible = row.is_visible;
formData.is_visible = Boolean(row.is_visible);
dialogVisible.value = true;
}
@@ -239,7 +299,7 @@ onMounted(loadData);
<NForm ref="formRef" :model="formData" label-placement="left" label-width="120">
<NDivider>基础配置</NDivider>
<NFormItem label="布局类型">
<NSelect v-model:value="formData.layout" :options="layoutOptions" />
<NSelect v-model:value="formData.layout" :options="layoutOptions" :render-label="renderLayoutLabel" :render-option="renderLayoutOption" />
</NFormItem>
<NFormItem label="主题">
<NRadioGroup v-model:value="formData.theme">
@@ -251,6 +311,7 @@ onMounted(loadData);
<NRadioButton value="color">纯色</NRadioButton>
<NRadioButton value="image">图片</NRadioButton>
<NRadioButton value="gradient">渐变</NRadioButton>
<NRadioButton value="video">视频</NRadioButton>
</NRadioGroup>
</NFormItem>
<NFormItem label="背景值">
@@ -336,3 +397,12 @@ onMounted(loadData);
</NModal>
</NSpace>
</template>
<style>
/* 布局类型下拉:收起态(输入框内)隐藏 desc,下拉展开列表里始终显示 desc */
.n-base-selection-label .layout-desc,
.n-base-selection-tag__content .layout-desc,
.n-base-selection-tags .layout-desc {
display: none;
}
</style>
@@ -74,7 +74,7 @@ const columns = computed<DataTableColumns<Api.Www.Product>>(() => [
width: 80,
render(row) {
return h(NSwitch, {
value: row.is_visible,
value: Boolean(row.is_visible),
onUpdateValue: (val: boolean) => handleToggleVisible(row, val)
});
}
@@ -155,7 +155,7 @@ function handleEdit(row: Api.Www.Product) {
formData.intro_zh = row.intro_zh;
formData.intro_en = row.intro_en;
formData.cover_url = row.cover_url;
formData.is_visible = row.is_visible;
formData.is_visible = Boolean(row.is_visible);
dialogVisible.value = true;
}
@@ -45,7 +45,7 @@ const columns: DataTableColumns<Api.Www.ProductSeries> = [
width: 80,
render(row) {
return h(NSwitch, {
value: row.is_visible,
value: Boolean(row.is_visible),
onUpdateValue: (val: boolean) => handleToggleVisible(row, val)
});
}
@@ -95,7 +95,7 @@ function handleEdit(row: Api.Www.ProductSeries) {
formData.subtitle_zh = row.subtitle_zh;
formData.subtitle_en = row.subtitle_en;
formData.cover_url = row.cover_url;
formData.is_visible = row.is_visible;
formData.is_visible = Boolean(row.is_visible);
dialogVisible.value = true;
}