This commit is contained in:
wenfp
2026-07-27 16:40:02 +08:00
parent 7f819cd775
commit 6f57070ad1
7 changed files with 1343 additions and 1 deletions
+1
View File
@@ -8,3 +8,4 @@ front/node_modules/
front/dist/ front/dist/
certs/ certs/
*.pem *.pem
front/tsconfig.tsbuildinfo
+43
View File
@@ -0,0 +1,43 @@
import { request } from './http';
export type AdSlot = {
id: number;
temple_id: number;
name: string;
code: string;
description: string | null;
sort_order: number;
is_active: boolean;
};
export type AdSlotPayload = {
name: string;
code: string;
description?: string | null;
sort_order?: number;
is_active?: boolean;
};
export function listAdSlots() {
return request<AdSlot[]>('/ad-slots');
}
export function createAdSlot(payload: AdSlotPayload) {
return request<AdSlot>('/ad-slots', {
method: 'POST',
json: payload,
});
}
export function updateAdSlot(id: number, payload: Partial<AdSlotPayload>) {
return request<AdSlot>(`/ad-slots/${id}`, {
method: 'PATCH',
json: payload,
});
}
export function deleteAdSlot(id: number) {
return request<void>(`/ad-slots/${id}`, {
method: 'DELETE',
});
}
+54
View File
@@ -0,0 +1,54 @@
import { request } from './http';
export type AdTargetType = 'app' | 'link';
export type Ad = {
id: number;
temple_id: number;
slot_id: number;
slot_name: string | null;
slot_code: string | null;
title: string;
image_url: string;
target_type: AdTargetType;
target_url: string | null;
product_id: number | null;
product_name: string | null;
sort_order: number;
is_active: boolean;
};
export type AdPayload = {
slot_id: number;
title: string;
image_url: string;
target_type: AdTargetType;
target_url?: string | null;
product_id?: number | null;
sort_order?: number;
is_active?: boolean;
};
export function listAds() {
return request<Ad[]>('/ads');
}
export function createAd(payload: AdPayload) {
return request<Ad>('/ads', {
method: 'POST',
json: payload,
});
}
export function updateAd(id: number, payload: Partial<AdPayload>) {
return request<Ad>(`/ads/${id}`, {
method: 'PATCH',
json: payload,
});
}
export function deleteAd(id: number) {
return request<void>(`/ads/${id}`, {
method: 'DELETE',
});
}
+64
View File
@@ -0,0 +1,64 @@
import { request } from './http';
export type FeatureIconTargetType = 'app' | 'link';
export type FeatureIcon = {
id: number;
temple_id: number;
title: string;
icon_url: string;
target_type: FeatureIconTargetType;
target_url: string | null;
product_id: number | null;
product_name: string | null;
sort_order: number;
is_active: boolean;
};
export type FeatureIconPayload = {
title: string;
icon_url: string;
target_type: FeatureIconTargetType;
target_url?: string | null;
product_id?: number | null;
sort_order?: number;
is_active?: boolean;
};
export function listFeatureIcons() {
return request<FeatureIcon[]>('/feature-icons');
}
export function listSourceFeatureIcons(sourceTempleId: number) {
return request<FeatureIcon[]>(`/feature-icons/source/${sourceTempleId}`);
}
export function createFeatureIcon(payload: FeatureIconPayload) {
return request<FeatureIcon>('/feature-icons', {
method: 'POST',
json: payload,
});
}
export function updateFeatureIcon(id: number, payload: Partial<FeatureIconPayload>) {
return request<FeatureIcon>(`/feature-icons/${id}`, {
method: 'PATCH',
json: payload,
});
}
export function deleteFeatureIcon(id: number) {
return request<void>(`/feature-icons/${id}`, {
method: 'DELETE',
});
}
export function copyFeatureIcons(sourceTempleId: number, iconIds: number[]) {
return request<FeatureIcon[]>('/feature-icons/copy', {
method: 'POST',
json: {
source_temple_id: sourceTempleId,
icon_ids: iconIds,
},
});
}
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Modal from 'ant-design-vue/es/modal';
import message from 'ant-design-vue/es/message';
import type { Rule } from 'ant-design-vue/es/form';
import type { ColumnsType } from 'ant-design-vue/es/table';
import { computed, onMounted, reactive, ref } from 'vue';
import {
createAdSlot,
deleteAdSlot,
listAdSlots,
updateAdSlot,
type AdSlot,
type AdSlotPayload,
} from '@/api/adSlots';
type AdSlotFormState = {
id?: number;
name: string;
code: string;
description: string;
sort_order: number;
is_active: boolean;
};
const adSlots = ref<AdSlot[]>([]);
const loading = ref(false);
const saving = ref(false);
const modalOpen = ref(false);
const formRef = ref();
const formState = reactive<AdSlotFormState>({
name: '',
code: '',
description: '',
sort_order: 0,
is_active: true,
});
const isEditing = computed(() => formState.id !== undefined);
const modalTitle = computed(() => (isEditing.value ? '编辑广告位' : '新建广告位'));
const columns: ColumnsType<AdSlot> = [
{ title: '广告位名称', dataIndex: 'name', width: 180 },
{ title: '广告位编码', dataIndex: 'code', width: 160 },
{ title: '说明', dataIndex: 'description' },
{ title: '排序', dataIndex: 'sort_order', width: 90 },
{ title: '是否启用', dataIndex: 'is_active', width: 100 },
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
];
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '请输入广告位名称', trigger: 'blur' }],
code: [
{ required: true, message: '请输入广告位编码', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '编码只支持字母、数字、下划线和短横线', trigger: 'blur' },
],
};
function resetForm() {
formState.id = undefined;
formState.name = '';
formState.code = '';
formState.description = '';
formState.sort_order = 0;
formState.is_active = true;
formRef.value?.clearValidate?.();
}
async function fetchAdSlots() {
loading.value = true;
try {
adSlots.value = await listAdSlots();
} catch (error) {
message.error(error instanceof Error ? error.message : '加载广告位失败,请先选择寺院');
} finally {
loading.value = false;
}
}
function openCreateModal() {
resetForm();
modalOpen.value = true;
}
function openEditModal(adSlot: AdSlot) {
formState.id = adSlot.id;
formState.name = adSlot.name;
formState.code = adSlot.code;
formState.description = adSlot.description || '';
formState.sort_order = adSlot.sort_order;
formState.is_active = adSlot.is_active;
modalOpen.value = true;
}
function buildPayload(): AdSlotPayload {
return {
code: formState.code.trim(),
description: formState.description.trim() || null,
is_active: formState.is_active,
name: formState.name,
sort_order: formState.sort_order,
};
}
async function submitForm() {
await formRef.value?.validate?.();
saving.value = true;
try {
if (isEditing.value && formState.id !== undefined) {
await updateAdSlot(formState.id, buildPayload());
message.success('广告位已更新');
} else {
await createAdSlot(buildPayload());
message.success('广告位已创建');
}
modalOpen.value = false;
resetForm();
await fetchAdSlots();
} catch (error) {
message.error(error instanceof Error ? error.message : '保存广告位失败');
} finally {
saving.value = false;
}
}
function confirmDelete(adSlot: AdSlot) {
Modal.confirm({
title: '删除广告位',
content: `确认删除广告位 ${adSlot.name}`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
await deleteAdSlot(adSlot.id);
message.success('广告位已删除');
await fetchAdSlots();
},
});
}
onMounted(fetchAdSlots);
</script>
<template>
<section class="workspace">
<div class="page-heading">
<h1>广告位管理</h1>
<a-space>
<a-button :loading="loading" @click="fetchAdSlots">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="openCreateModal">
<template #icon><PlusOutlined /></template>
新建广告位
</a-button>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="adSlots"
:loading="loading"
:pagination="{ pageSize: 8, showSizeChanger: false }"
bordered
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'description'">
{{ record.description || '-' }}
</template>
<template v-if="column.dataIndex === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'default'">
{{ record.is_active ? '启用' : '停用' }}
</a-tag>
</template>
<template v-if="column.key === 'actions'">
<a-space>
<a-tooltip title="编辑">
<a-button type="text" @click="openEditModal(record)">
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-button type="text" danger @click="confirmDelete(record)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-space>
</template>
</template>
</a-table>
</section>
<a-modal
v-model:open="modalOpen"
:confirm-loading="saving"
:title="modalTitle"
ok-text="保存"
cancel-text="取消"
@ok="submitForm"
>
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-form-item label="广告位名称" name="name">
<a-input v-model:value="formState.name" placeholder="首页广告" />
</a-form-item>
<a-form-item label="广告位编码" name="code">
<a-input v-model:value="formState.code" placeholder="home_banner" />
</a-form-item>
<a-form-item label="说明" name="description">
<a-textarea v-model:value="formState.description" :rows="3" placeholder="用于首页顶部广告展示" />
</a-form-item>
<a-form-item label="排序" name="sort_order">
<a-input-number v-model:value="formState.sort_order" :min="0" class="full-width-control" />
</a-form-item>
<a-form-item label="是否启用" name="is_active">
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
</a-form-item>
</a-form>
</a-modal>
</template>
+428
View File
@@ -0,0 +1,428 @@
<script setup lang="ts">
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Modal from 'ant-design-vue/es/modal';
import message from 'ant-design-vue/es/message';
import type { Rule } from 'ant-design-vue/es/form';
import type { ColumnsType } from 'ant-design-vue/es/table';
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
import type { UploadRequestOption } from 'ant-design-vue/es/vc-upload/interface';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import {
createAd,
deleteAd,
listAds,
updateAd,
type Ad,
type AdPayload,
type AdTargetType,
} from '@/api/ads';
import { listAdSlots, type AdSlot } from '@/api/adSlots';
import { listProducts, type Product } from '@/api/products';
import { uploadImage, type UploadResponse } from '@/api/uploads';
type AdFormState = {
id?: number;
slot_id?: number;
title: string;
image_url: string;
target_type: AdTargetType;
target_url: string;
product_id?: number;
sort_order: number;
is_active: boolean;
};
const ads = ref<Ad[]>([]);
const adSlots = ref<AdSlot[]>([]);
const products = ref<Product[]>([]);
const loading = ref(false);
const saving = ref(false);
const modalOpen = ref(false);
const formRef = ref();
const imageFileList = ref<UploadFile[]>([]);
const formState = reactive<AdFormState>({
slot_id: undefined,
title: '',
image_url: '',
target_type: 'link',
target_url: '',
product_id: undefined,
sort_order: 0,
is_active: true,
});
const isEditing = computed(() => formState.id !== undefined);
const modalTitle = computed(() => (isEditing.value ? '编辑广告' : '新建广告'));
const slotOptions = computed(() =>
adSlots.value.map((slot) => ({
label: `${slot.name}${slot.code}`,
value: slot.id,
})),
);
const productOptions = computed(() =>
products.value.map((product) => ({
label: product.alias ? `${product.name}${product.alias}` : product.name,
value: product.id,
})),
);
const columns: ColumnsType<Ad> = [
{ title: '广告图', dataIndex: 'image_url', width: 150 },
{ title: '广告名称', dataIndex: 'title', width: 180 },
{ title: '广告位', dataIndex: 'slot_name', width: 180 },
{ title: '跳转目标', key: 'target', width: 260 },
{ title: '类型', dataIndex: 'target_type', width: 100 },
{ title: '是否启用', dataIndex: 'is_active', width: 100 },
{ title: '排序', dataIndex: 'sort_order', width: 90 },
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
];
const rules: Record<string, Rule[]> = {
slot_id: [{ required: true, message: '请选择广告位', trigger: 'change' }],
title: [{ required: true, message: '请输入广告名称', trigger: 'blur' }],
image_url: [{ required: true, message: '请上传广告图', trigger: 'change' }],
};
function resetForm() {
formState.id = undefined;
formState.slot_id = undefined;
formState.title = '';
formState.image_url = '';
formState.target_type = 'link';
formState.target_url = '';
formState.product_id = undefined;
formState.sort_order = 0;
formState.is_active = true;
imageFileList.value = [];
formRef.value?.clearValidate?.();
}
function getFileName(url: string) {
try {
return new URL(url).pathname.split('/').filter(Boolean).pop() || 'ad';
} catch {
return url.split('/').filter(Boolean).pop() || 'ad';
}
}
function createUploadFile(url: string): UploadFile {
return {
uid: `ad-${url}`,
name: getFileName(url),
status: 'done',
url,
};
}
function syncImageFileList() {
imageFileList.value = formState.image_url ? [createUploadFile(formState.image_url)] : [];
}
function getRequestFile(option: UploadRequestOption) {
return option.file instanceof Blob ? option.file : null;
}
async function handleImageUpload(option: UploadRequestOption) {
const file = getRequestFile(option);
if (!file) {
message.error('请选择图片文件');
option.onError?.(new Error('请选择图片文件'));
return;
}
try {
const result = await uploadImage(file, 'ads');
const response = result as UploadResponse;
formState.image_url = response.url;
imageFileList.value = [
{
uid: String((option.file as Blob & { uid?: string }).uid || Date.now()),
name: getFileName(response.url),
status: 'done',
url: response.url,
},
];
option.onSuccess?.(result);
} catch (error) {
const uploadError = error instanceof Error ? error : new Error('上传失败');
message.error(uploadError.message);
option.onError?.(uploadError);
}
}
function removeImage() {
formState.image_url = '';
imageFileList.value = [];
return true;
}
function getTargetText(ad: Ad) {
if (ad.target_type === 'app') {
return ad.product_name || '未选择商品';
}
return ad.target_url || '-';
}
async function fetchAds() {
loading.value = true;
try {
const [adRows, slotRows, productRows] = await Promise.all([listAds(), listAdSlots(), listProducts()]);
ads.value = adRows;
adSlots.value = slotRows;
products.value = productRows;
} catch (error) {
message.error(error instanceof Error ? error.message : '加载广告失败,请先选择寺院');
} finally {
loading.value = false;
}
}
function openCreateModal() {
resetForm();
if (slotOptions.value.length > 0) {
formState.slot_id = slotOptions.value[0].value;
}
modalOpen.value = true;
}
function openEditModal(ad: Ad) {
formState.id = ad.id;
formState.slot_id = ad.slot_id;
formState.title = ad.title;
formState.image_url = ad.image_url;
formState.target_type = ad.target_type;
formState.target_url = ad.target_url || '';
formState.product_id = ad.product_id || undefined;
formState.sort_order = ad.sort_order;
formState.is_active = ad.is_active;
syncImageFileList();
modalOpen.value = true;
}
function buildPayload(): AdPayload {
const payload: AdPayload = {
image_url: formState.image_url,
is_active: formState.is_active,
slot_id: Number(formState.slot_id),
sort_order: formState.sort_order,
target_type: formState.target_type,
title: formState.title,
};
if (formState.target_type === 'link') {
payload.target_url = formState.target_url.trim();
payload.product_id = null;
} else {
payload.target_url = null;
payload.product_id = formState.product_id || null;
}
return payload;
}
async function submitForm() {
await formRef.value?.validate?.();
if (!formState.slot_id) {
message.error('请选择广告位');
return;
}
if (formState.target_type === 'link' && !formState.target_url.trim()) {
message.error('请输入链接地址');
return;
}
if (formState.target_type === 'app' && !formState.product_id) {
message.error('请选择商品');
return;
}
saving.value = true;
try {
if (isEditing.value && formState.id !== undefined) {
await updateAd(formState.id, buildPayload());
message.success('广告已更新');
} else {
await createAd(buildPayload());
message.success('广告已创建');
}
modalOpen.value = false;
resetForm();
await fetchAds();
} catch (error) {
message.error(error instanceof Error ? error.message : '保存广告失败');
} finally {
saving.value = false;
}
}
function confirmDelete(ad: Ad) {
Modal.confirm({
title: '删除广告',
content: `确认删除广告 ${ad.title}`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
await deleteAd(ad.id);
message.success('广告已删除');
await fetchAds();
},
});
}
watch(
() => formState.target_type,
(targetType) => {
if (targetType === 'link') {
formState.product_id = undefined;
} else {
formState.target_url = '';
}
},
);
onMounted(fetchAds);
</script>
<template>
<section class="workspace">
<div class="page-heading">
<h1>广告管理</h1>
<a-space>
<a-button :loading="loading" @click="fetchAds">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="openCreateModal">
<template #icon><PlusOutlined /></template>
新建广告
</a-button>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="ads"
:loading="loading"
:pagination="{ pageSize: 8, showSizeChanger: false }"
:scroll="{ x: 1180 }"
bordered
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'image_url'">
<a-image :src="record.image_url" :width="96" :height="48" class="ad-preview" />
</template>
<template v-if="column.dataIndex === 'slot_name'">
{{ record.slot_name || '-' }}
</template>
<template v-if="column.key === 'target'">
{{ getTargetText(record) }}
</template>
<template v-if="column.dataIndex === 'target_type'">
<a-tag :color="record.target_type === 'link' ? 'blue' : 'purple'">
{{ record.target_type === 'link' ? '链接' : '应用' }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'default'">
{{ record.is_active ? '启用' : '停用' }}
</a-tag>
</template>
<template v-if="column.key === 'actions'">
<a-space>
<a-tooltip title="编辑">
<a-button type="text" @click="openEditModal(record)">
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-button type="text" danger @click="confirmDelete(record)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-space>
</template>
</template>
</a-table>
</section>
<a-modal
v-model:open="modalOpen"
:confirm-loading="saving"
:title="modalTitle"
width="720px"
ok-text="保存"
cancel-text="取消"
@ok="submitForm"
>
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="广告位" name="slot_id">
<a-select v-model:value="formState.slot_id" :options="slotOptions" placeholder="请选择广告位" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="广告名称" name="title">
<a-input v-model:value="formState.title" placeholder="首页头图广告" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="广告图片" name="image_url">
<a-upload
v-model:file-list="imageFileList"
accept="image/*"
list-type="picture-card"
:custom-request="handleImageUpload"
:max-count="1"
@remove="removeImage"
>
<div v-if="imageFileList.length < 1">
<PlusOutlined />
<div class="upload-text">上传图片</div>
</div>
</a-upload>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="类型" name="target_type">
<a-segmented
v-model:value="formState.target_type"
:options="[
{ label: '链接', value: 'link' },
{ label: '应用', value: 'app' },
]"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="排序" name="sort_order">
<a-input-number v-model:value="formState.sort_order" :min="0" class="full-width-control" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="是否启用" name="is_active">
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item v-if="formState.target_type === 'link'" label="链接地址" name="target_url">
<a-input v-model:value="formState.target_url" placeholder="https://example.com 或 /pages/detail" />
</a-form-item>
<a-form-item v-else label="选择商品" name="product_id">
<a-select
v-model:value="formState.product_id"
:options="productOptions"
placeholder="请选择当前寺院商品"
show-search
option-filter-prop="label"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
@@ -0,0 +1,528 @@
<script setup lang="ts">
import {
CopyOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import Modal from 'ant-design-vue/es/modal';
import message from 'ant-design-vue/es/message';
import type { Rule } from 'ant-design-vue/es/form';
import type { ColumnsType } from 'ant-design-vue/es/table';
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
import type { UploadRequestOption } from 'ant-design-vue/es/vc-upload/interface';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import {
copyFeatureIcons,
createFeatureIcon,
deleteFeatureIcon,
listFeatureIcons,
listSourceFeatureIcons,
updateFeatureIcon,
type FeatureIcon,
type FeatureIconPayload,
type FeatureIconTargetType,
} from '@/api/featureIcons';
import { CURRENT_TEMPLE_ID_KEY, DEFAULT_TEMPLE_ID } from '@/api/http';
import { listProducts, type Product } from '@/api/products';
import { listTemples, type Temple } from '@/api/temples';
import { uploadImage, type UploadResponse } from '@/api/uploads';
type IconFormState = {
id?: number;
title: string;
icon_url: string;
target_type: FeatureIconTargetType;
target_url: string;
product_id?: number;
sort_order: number;
is_active: boolean;
};
const icons = ref<FeatureIcon[]>([]);
const products = ref<Product[]>([]);
const temples = ref<Temple[]>([]);
const sourceIcons = ref<FeatureIcon[]>([]);
const selectedSourceIconIds = ref<number[]>([]);
const loading = ref(false);
const saving = ref(false);
const syncLoading = ref(false);
const sourceLoading = ref(false);
const modalOpen = ref(false);
const syncModalOpen = ref(false);
const formRef = ref();
const iconFileList = ref<UploadFile[]>([]);
const currentTempleId = computed(() => Number(localStorage.getItem(CURRENT_TEMPLE_ID_KEY) || DEFAULT_TEMPLE_ID));
const formState = reactive<IconFormState>({
title: '',
icon_url: '',
target_type: 'link',
target_url: '',
product_id: undefined,
sort_order: 0,
is_active: true,
});
const syncState = reactive({
source_temple_id: undefined as number | undefined,
});
const isEditing = computed(() => formState.id !== undefined);
const modalTitle = computed(() => (isEditing.value ? '编辑图标' : '添加图标'));
const productOptions = computed(() =>
products.value.map((product) => ({
label: product.alias ? `${product.name}${product.alias}` : product.name,
value: product.id,
})),
);
const templeOptions = computed(() =>
temples.value
.filter((temple) => temple.id !== currentTempleId.value)
.map((temple) => ({
label: temple.name,
value: temple.id,
})),
);
const columns: ColumnsType<FeatureIcon> = [
{ title: '图标预览', dataIndex: 'icon_url', width: 110 },
{ title: '图标列表', dataIndex: 'title', width: 160 },
{ title: '跳转地址', key: 'target', width: 240 },
{ title: '类型', dataIndex: 'target_type', width: 100 },
{ title: '是否启用', dataIndex: 'is_active', width: 100 },
{ title: '排序', dataIndex: 'sort_order', width: 90 },
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
];
const sourceColumns: ColumnsType<FeatureIcon> = [
{ title: '图标', dataIndex: 'icon_url', width: 90 },
{ title: '名称', dataIndex: 'title' },
{ title: '类型', dataIndex: 'target_type', width: 100 },
{ title: '状态', dataIndex: 'is_active', width: 100 },
];
const sourceRowSelection = computed(() => ({
selectedRowKeys: selectedSourceIconIds.value,
onChange: (keys: (string | number)[]) => {
selectedSourceIconIds.value = keys.map(Number);
},
}));
const rules: Record<string, Rule[]> = {
title: [{ required: true, message: '请输入图标名称', trigger: 'blur' }],
icon_url: [{ required: true, message: '请上传图标', trigger: 'change' }],
target_type: [{ required: true, message: '请选择类型', trigger: 'change' }],
};
function resetForm() {
formState.id = undefined;
formState.title = '';
formState.icon_url = '';
formState.target_type = 'link';
formState.target_url = '';
formState.product_id = undefined;
formState.sort_order = 0;
formState.is_active = true;
iconFileList.value = [];
formRef.value?.clearValidate?.();
}
function getFileName(url: string) {
try {
return new URL(url).pathname.split('/').filter(Boolean).pop() || 'icon';
} catch {
return url.split('/').filter(Boolean).pop() || 'icon';
}
}
function createUploadFile(url: string): UploadFile {
return {
uid: `icon-${url}`,
name: getFileName(url),
status: 'done',
url,
};
}
function syncIconFileList() {
iconFileList.value = formState.icon_url ? [createUploadFile(formState.icon_url)] : [];
}
function getRequestFile(option: UploadRequestOption) {
return option.file instanceof Blob ? option.file : null;
}
async function handleIconUpload(option: UploadRequestOption) {
const file = getRequestFile(option);
if (!file) {
message.error('请选择图片文件');
option.onError?.(new Error('请选择图片文件'));
return;
}
try {
const result = await uploadImage(file, 'feature-icons');
formState.icon_url = result.url;
const response = result as UploadResponse;
iconFileList.value = [
{
uid: String((option.file as Blob & { uid?: string }).uid || Date.now()),
name: getFileName(response.url),
status: 'done',
url: response.url,
},
];
option.onSuccess?.(result);
} catch (error) {
const uploadError = error instanceof Error ? error : new Error('上传失败');
message.error(uploadError.message);
option.onError?.(uploadError);
}
}
function removeIconImage() {
formState.icon_url = '';
iconFileList.value = [];
return true;
}
function getTargetText(icon: FeatureIcon) {
if (icon.target_type === 'app') {
return icon.product_name || '未选择商品';
}
return icon.target_url || '-';
}
async function fetchBaseOptions() {
const [productRows, templeRows] = await Promise.all([listProducts(), listTemples()]);
products.value = productRows;
temples.value = templeRows;
}
async function fetchIcons() {
loading.value = true;
try {
icons.value = await listFeatureIcons();
await fetchBaseOptions();
} catch (error) {
message.error(error instanceof Error ? error.message : '加载图标失败,请先选择寺院');
} finally {
loading.value = false;
}
}
function openCreateModal() {
resetForm();
modalOpen.value = true;
}
function openEditModal(icon: FeatureIcon) {
formState.id = icon.id;
formState.title = icon.title;
formState.icon_url = icon.icon_url;
formState.target_type = icon.target_type;
formState.target_url = icon.target_url || '';
formState.product_id = icon.product_id || undefined;
formState.sort_order = icon.sort_order;
formState.is_active = icon.is_active;
syncIconFileList();
modalOpen.value = true;
}
function buildPayload(): FeatureIconPayload {
const payload: FeatureIconPayload = {
icon_url: formState.icon_url,
is_active: formState.is_active,
sort_order: formState.sort_order,
target_type: formState.target_type,
title: formState.title,
};
if (formState.target_type === 'link') {
payload.target_url = formState.target_url.trim();
payload.product_id = null;
} else {
payload.target_url = null;
payload.product_id = formState.product_id || null;
}
return payload;
}
async function submitForm() {
await formRef.value?.validate?.();
if (formState.target_type === 'link' && !formState.target_url.trim()) {
message.error('请输入链接地址');
return;
}
if (formState.target_type === 'app' && !formState.product_id) {
message.error('请选择商品');
return;
}
saving.value = true;
try {
if (isEditing.value && formState.id !== undefined) {
await updateFeatureIcon(formState.id, buildPayload());
message.success('图标已更新');
} else {
await createFeatureIcon(buildPayload());
message.success('图标已添加');
}
modalOpen.value = false;
resetForm();
await fetchIcons();
} catch (error) {
message.error(error instanceof Error ? error.message : '保存图标失败');
} finally {
saving.value = false;
}
}
function confirmDelete(icon: FeatureIcon) {
Modal.confirm({
title: '删除图标',
content: `确认删除图标 ${icon.title}`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
await deleteFeatureIcon(icon.id);
message.success('图标已删除');
await fetchIcons();
},
});
}
async function openSyncModal() {
syncModalOpen.value = true;
selectedSourceIconIds.value = [];
sourceIcons.value = [];
await fetchBaseOptions();
if (!syncState.source_temple_id && templeOptions.value.length > 0) {
syncState.source_temple_id = templeOptions.value[0].value;
}
await fetchSourceIcons();
}
async function fetchSourceIcons() {
if (!syncState.source_temple_id) {
sourceIcons.value = [];
return;
}
sourceLoading.value = true;
try {
sourceIcons.value = await listSourceFeatureIcons(syncState.source_temple_id);
} catch (error) {
message.error(error instanceof Error ? error.message : '加载来源图标失败');
} finally {
sourceLoading.value = false;
}
}
async function submitSync() {
if (!syncState.source_temple_id) {
message.error('请选择来源寺院');
return;
}
if (selectedSourceIconIds.value.length === 0) {
message.error('请选择要同步的图标');
return;
}
syncLoading.value = true;
try {
await copyFeatureIcons(syncState.source_temple_id, selectedSourceIconIds.value);
message.success('图标已同步到当前寺院');
syncModalOpen.value = false;
selectedSourceIconIds.value = [];
await fetchIcons();
} catch (error) {
message.error(error instanceof Error ? error.message : '同步图标失败');
} finally {
syncLoading.value = false;
}
}
watch(
() => formState.target_type,
(targetType) => {
if (targetType === 'link') {
formState.product_id = undefined;
} else {
formState.target_url = '';
}
},
);
onMounted(fetchIcons);
</script>
<template>
<section class="workspace">
<div class="page-heading">
<h1>图标管理</h1>
<a-space>
<a-button :loading="loading" @click="fetchIcons">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button @click="openSyncModal">
<template #icon><CopyOutlined /></template>
同步其他寺院图标
</a-button>
<a-button type="primary" @click="openCreateModal">
<template #icon><PlusOutlined /></template>
添加图标
</a-button>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="icons"
:loading="loading"
:pagination="{ pageSize: 8, showSizeChanger: false }"
:scroll="{ x: 940 }"
bordered
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'icon_url'">
<a-image :src="record.icon_url" :width="48" :height="48" class="square-preview" />
</template>
<template v-if="column.key === 'target'">
{{ getTargetText(record) }}
</template>
<template v-if="column.dataIndex === 'target_type'">
<a-tag :color="record.target_type === 'link' ? 'blue' : 'purple'">
{{ record.target_type === 'link' ? '链接' : '应用' }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'default'">
{{ record.is_active ? '启用' : '停用' }}
</a-tag>
</template>
<template v-if="column.key === 'actions'">
<a-space>
<a-tooltip title="编辑">
<a-button type="text" @click="openEditModal(record)">
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-button type="text" danger @click="confirmDelete(record)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-space>
</template>
</template>
</a-table>
</section>
<a-modal
v-model:open="modalOpen"
:confirm-loading="saving"
:title="modalTitle"
ok-text="保存"
cancel-text="取消"
@ok="submitForm"
>
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-form-item label="图标名称" name="title">
<a-input v-model:value="formState.title" placeholder="功德商城" />
</a-form-item>
<a-form-item label="图标" name="icon_url" extra="建议图标宽高 120px * 120px">
<a-upload
v-model:file-list="iconFileList"
accept="image/*"
list-type="picture-card"
:custom-request="handleIconUpload"
:max-count="1"
@remove="removeIconImage"
>
<div v-if="iconFileList.length < 1">
<PlusOutlined />
<div class="upload-text">上传图标</div>
</div>
</a-upload>
</a-form-item>
<a-form-item label="类型" name="target_type">
<a-segmented
v-model:value="formState.target_type"
:options="[
{ label: '链接', value: 'link' },
{ label: '应用', value: 'app' },
]"
/>
</a-form-item>
<a-form-item v-if="formState.target_type === 'link'" label="链接地址" name="target_url">
<a-input v-model:value="formState.target_url" placeholder="https://example.com 或 /pages/detail" />
</a-form-item>
<a-form-item v-else label="选择商品" name="product_id">
<a-select
v-model:value="formState.product_id"
:options="productOptions"
placeholder="请选择当前寺院商品"
show-search
option-filter-prop="label"
/>
</a-form-item>
<a-form-item label="排序" name="sort_order">
<a-input-number v-model:value="formState.sort_order" :min="0" class="full-width-control" />
</a-form-item>
<a-form-item label="是否启用" name="is_active">
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="syncModalOpen"
:confirm-loading="syncLoading"
title="同步其他寺院图标"
width="760px"
ok-text="复制到当前寺院"
cancel-text="取消"
@ok="submitSync"
>
<a-space direction="vertical" class="full-width-control" size="middle">
<a-select
v-model:value="syncState.source_temple_id"
:options="templeOptions"
class="full-width-control"
placeholder="选择来源寺院"
@change="fetchSourceIcons"
/>
<a-table
:columns="sourceColumns"
:data-source="sourceIcons"
:loading="sourceLoading"
:pagination="{ pageSize: 5, showSizeChanger: false }"
:row-selection="sourceRowSelection"
bordered
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'icon_url'">
<a-image :src="record.icon_url" :width="40" :height="40" class="square-preview" />
</template>
<template v-if="column.dataIndex === 'target_type'">
{{ record.target_type === 'link' ? '链接' : '应用' }}
</template>
<template v-if="column.dataIndex === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'default'">
{{ record.is_active ? '启用' : '停用' }}
</a-tag>
</template>
</template>
</a-table>
</a-space>
</a-modal>
</template>