This commit introduces a new 'DesignedRemark' component to display an attribution link. - Adds the Pacifico font for custom styling of the remark. - Creates and registers the global `DesignedRemark` Vue component. - Integrates the component into the mobile sponsor list page.
301 lines
8.6 KiB
JavaScript
301 lines
8.6 KiB
JavaScript
const { createApp, ref, computed, onMounted } = Vue;
|
||
|
||
const SponsorCard = {
|
||
props: {
|
||
sponsor: { type: Object, required: true },
|
||
},
|
||
template: "#sponsor-card-template",
|
||
};
|
||
|
||
const DesignedRemark = {
|
||
template: "#designed-remark-template",
|
||
};
|
||
|
||
const app = createApp({
|
||
setup() {
|
||
const eventTitle = ref("");
|
||
const logos = ref([]);
|
||
const sponsorList = ref([]); // Load from JSON
|
||
const specialSponsor = ref("");
|
||
const showStats = ref(false);
|
||
const activeCategory = ref("all");
|
||
|
||
const categories = [
|
||
{
|
||
type: "all",
|
||
name: "全部",
|
||
icon: "fas fa-list",
|
||
color: "text-blue-500",
|
||
},
|
||
{
|
||
type: "cash",
|
||
name: "现金",
|
||
icon: "fas fa-money-bill-wave",
|
||
color: "text-green-500",
|
||
pillBg: "bg-green-100",
|
||
pillColor: "text-green-800",
|
||
},
|
||
{
|
||
type: "item",
|
||
name: "标品",
|
||
icon: "fas fa-gift",
|
||
color: "text-purple-500",
|
||
pillBg: "bg-purple-100",
|
||
pillColor: "text-purple-800",
|
||
},
|
||
{
|
||
type: "table",
|
||
name: "桌位",
|
||
icon: "fas fa-utensils",
|
||
color: "text-orange-500",
|
||
pillBg: "bg-orange-100",
|
||
pillColor: "text-orange-800",
|
||
},
|
||
];
|
||
|
||
const typePriority = {
|
||
cash: 4,
|
||
"cash-group": 3,
|
||
table: 2,
|
||
item: 1,
|
||
};
|
||
|
||
const groupByCashLessThan = ref(0);
|
||
|
||
function sortSponsors(list) {
|
||
return list.sort((a, b) => {
|
||
// 先按 type 优先级排序
|
||
const typeDiff = typePriority[b.type] - typePriority[a.type];
|
||
if (typeDiff !== 0) return typeDiff;
|
||
|
||
// 若 type 相同,再按 amount 从大到小
|
||
if (b.amount !== a.amount) return b.amount - a.amount;
|
||
|
||
// 若 amount 相同,最后按名称排序(可选)
|
||
return a.name.localeCompare(b.name, "zh");
|
||
});
|
||
}
|
||
|
||
// 统计数据
|
||
const stats = computed(() => {
|
||
const result = {
|
||
totalSponsors: 0,
|
||
totalCash: 0,
|
||
totalItems: 0,
|
||
totalTables: 0,
|
||
typeDistribution: [],
|
||
};
|
||
|
||
// 计算各类赞助商数量
|
||
const typeCounts = {
|
||
cash: 0,
|
||
"cash-group": 0,
|
||
item: 0,
|
||
table: 0,
|
||
};
|
||
|
||
// 计算总额
|
||
sponsorList.value.forEach((sponsor) => {
|
||
if (sponsor.type === "cash") {
|
||
result.totalCash += sponsor.amount;
|
||
typeCounts.cash++;
|
||
} else if (sponsor.type === "cash-group") {
|
||
result.totalCash += sponsor.amount * sponsor.children.length;
|
||
typeCounts["cash-group"]++;
|
||
} else if (sponsor.type === "item") {
|
||
result.totalItems++;
|
||
typeCounts.item++;
|
||
} else if (sponsor.type === "table") {
|
||
result.totalTables += sponsor.amount;
|
||
typeCounts.table++;
|
||
}
|
||
});
|
||
|
||
// 计算总赞助商数量
|
||
result.totalSponsors = sponsorList.value.length;
|
||
|
||
// 计算类型分布
|
||
const total = result.totalSponsors;
|
||
result.typeDistribution = [
|
||
{
|
||
name: "现金赞助",
|
||
count: typeCounts.cash + typeCounts["cash-group"],
|
||
percentage: Math.round(
|
||
((typeCounts.cash + typeCounts["cash-group"]) / total) * 100
|
||
),
|
||
color: "bg-green-500",
|
||
},
|
||
{
|
||
name: "标品赞助",
|
||
count: typeCounts.item,
|
||
percentage: Math.round((typeCounts.item / total) * 100),
|
||
color: "bg-purple-500",
|
||
},
|
||
{
|
||
name: "桌位赞助",
|
||
count: typeCounts.table,
|
||
percentage: Math.round((typeCounts.table / total) * 100),
|
||
color: "bg-orange-500",
|
||
},
|
||
];
|
||
|
||
return result;
|
||
});
|
||
|
||
// 过滤赞助商
|
||
const filteredSponsors = computed(() => {
|
||
if (activeCategory.value === "all") {
|
||
return sponsorList.value;
|
||
}
|
||
return sponsorList.value.filter(
|
||
(sponsor) => sponsor.type === activeCategory.value
|
||
);
|
||
});
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
const [sponsorListResult] = await Promise.all([
|
||
fetch("../../sponsorList.json"),
|
||
]);
|
||
if (!sponsorListResult.ok) {
|
||
throw new Error(
|
||
"Error while loading sponsorList: " + sponsorListResult.status
|
||
);
|
||
}
|
||
const sponsorListJsonData = await sponsorListResult.json();
|
||
|
||
eventTitle.value = sponsorListJsonData.eventTitle || "活动名称";
|
||
specialSponsor.value = sponsorListJsonData.specialSponsors || "";
|
||
logos.value = sponsorListJsonData.logos || [];
|
||
|
||
sponsorList.value = (sponsorListJsonData.sponsorList || []).reduce(
|
||
(acc, s, idx) => {
|
||
const category = categories.find((c) => c.type === s.type);
|
||
|
||
const sponsor = {
|
||
...s,
|
||
displayAmount:
|
||
s.type == "cash" ? s.amount.toLocaleString("en-MY") : s.amount,
|
||
seats: s.type == "table" ? tableToSeats(s.amount) : "",
|
||
pillBg: category?.pillBg,
|
||
pillColor: category?.pillColor,
|
||
_uid: `s-${idx}`,
|
||
};
|
||
|
||
// 如果是现金赞助且金额小于阈值
|
||
if (s.type === "cash" && s.amount < groupByCashLessThan.value) {
|
||
// 查找是否已存在该金额的分组
|
||
const groupId = `group-${s.amount}`;
|
||
let amountGroup = acc.find((item) => item._uid === groupId);
|
||
|
||
if (!amountGroup) {
|
||
// 创建新的金额分组
|
||
amountGroup = {
|
||
name: "其他赞助商",
|
||
type: "cash-group",
|
||
displayAmount:
|
||
s.type == "cash"
|
||
? s.amount.toLocaleString("en-MY")
|
||
: s.amount,
|
||
amount: s.amount, // 保持原始数字,不格式化
|
||
children: [],
|
||
_uid: groupId,
|
||
};
|
||
acc.push(amountGroup);
|
||
}
|
||
|
||
// 将赞助商名称添加到分组的children中
|
||
amountGroup.children.push(s.name);
|
||
} else {
|
||
// 其他赞助商直接添加到结果中
|
||
acc.push(sponsor);
|
||
}
|
||
|
||
return acc;
|
||
},
|
||
[]
|
||
);
|
||
// Sort SponsorList by type and amount
|
||
sponsorList.value = sortSponsors(sponsorList.value);
|
||
console.log(sponsorList.value);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
onMounted(async () => {
|
||
await loadData();
|
||
});
|
||
|
||
const tableToSeats = (input) => {
|
||
if (input === null || input === undefined || input === "") return "";
|
||
|
||
if (input === 0.5) return "半席";
|
||
|
||
const n = Number(input);
|
||
if (Number.isNaN(n)) throw new TypeError("输入不是有效数字: " + input);
|
||
|
||
const eps = 1e-9;
|
||
const sign = n < 0 ? "负" : "";
|
||
const abs = Math.abs(n);
|
||
|
||
// 把数值四舍五入到最接近的 0.5(保证容错)
|
||
const rounded = Math.round(abs * 2) / 2;
|
||
|
||
// 分离整数和小数部分
|
||
let intPart = Math.floor(rounded + eps);
|
||
const rem = rounded - intPart; // 只可能是 0 或 0.5
|
||
|
||
// 如果 rounded 恰好是 X.0 的情况下 rem ~ 0;如果是 X.5 则 rem ~ 0.5
|
||
// 特殊:如果 rem === 1(极罕见)就进位(上面已用 round 避免)
|
||
// 小范围容错处理
|
||
const isHalf = Math.abs(rem - 0.5) < eps;
|
||
const isInteger = Math.abs(rem) < eps;
|
||
|
||
// 数字到小范围中文(只对 0/1/2 做汉字)
|
||
const smallChinese = (num) => {
|
||
if (num === 0) return "零";
|
||
if (num === 1) return "一";
|
||
if (num === 2) return "两";
|
||
return null;
|
||
};
|
||
|
||
const numLabel = (() => {
|
||
const c = smallChinese(intPart);
|
||
return c !== null ? c : String(intPart);
|
||
})();
|
||
|
||
// 判断 numLabel 是阿拉伯数字还是汉字(用于决定是否在数字与“席”之间加空格)
|
||
const isDigits = /^\d+$/.test(numLabel);
|
||
|
||
if (isInteger) {
|
||
// 整数
|
||
return isDigits ? `${sign}${numLabel} 席` : `${sign}${numLabel}席`;
|
||
} else if (isHalf) {
|
||
// 半席
|
||
return isDigits ? `${sign}${numLabel} 席半` : `${sign}${numLabel}席半`;
|
||
} else {
|
||
// 理论上不会到这里(因为已 round 到 0.5),但兜底返回字符串
|
||
return `${sign}${numLabel} 席`;
|
||
}
|
||
};
|
||
|
||
return {
|
||
eventTitle,
|
||
logos,
|
||
sponsorList,
|
||
specialSponsor,
|
||
showStats,
|
||
activeCategory,
|
||
categories,
|
||
stats,
|
||
filteredSponsors,
|
||
tableToSeats,
|
||
};
|
||
},
|
||
});
|
||
|
||
app.component("designed-remark", DesignedRemark); // 全局注册
|
||
app.component("sponsor-card", SponsorCard); // 全局注册
|
||
app.mount("#app");
|