update
This commit is contained in:
+2
-1
@@ -7,4 +7,5 @@ app.sqlite3
|
|||||||
front/node_modules/
|
front/node_modules/
|
||||||
front/dist/
|
front/dist/
|
||||||
certs/
|
certs/
|
||||||
*.pem
|
*.pem
|
||||||
|
front/tsconfig.tsbuildinfo
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user