hookehuyr

security(Masters): 修复XSS漏洞并增强错误处理

- 移除 v-html 使用,改用模板渲染防止 XSS 攻击
- 添加 API 请求错误处理 (try-catch)
- 使用可选链防止数组越界错误
- 添加 processImageUrl 函数安全处理图片 URL
- 改进可访问性 (aria-label, loading lazy)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
...@@ -9,11 +9,18 @@ ...@@ -9,11 +9,18 @@
9 @click="goDetail(item)" 9 @click="goDetail(item)"
10 > 10 >
11 <div class="item-image"> 11 <div class="item-image">
12 - <img :src="item.image" :alt="item.name" /> 12 + <img
13 + :src="item.image"
14 + :alt="`${item.role} ${item.name}`"
15 + :aria-label="`${item.role}:${item.name}`"
16 + loading="lazy"
17 + />
13 </div> 18 </div>
14 <div class="item-caption"> 19 <div class="item-caption">
15 <div class="item-role">{{ item.role }}</div> 20 <div class="item-role">{{ item.role }}</div>
16 - <div class="item-name" v-html="formatNameWithSuperscript(item.name)"></div> 21 + <div class="item-name">
22 + <sup class="name-sup">上</sup>{{ item.name.charAt(0) }}<sup class="name-sup">下</sup>{{ item.name.slice(1) }}
23 + </div>
17 </div> 24 </div>
18 </div> 25 </div>
19 </section> 26 </section>
...@@ -27,11 +34,43 @@ ...@@ -27,11 +34,43 @@
27 @click="goDetail(item)" 34 @click="goDetail(item)"
28 > 35 >
29 <div class="item-image"> 36 <div class="item-image">
30 - <img :src="item.image" :alt="item.name" /> 37 + <img
38 + :src="item.image"
39 + :alt="`${item.role} ${item.name}`"
40 + :aria-label="`${item.role}:${item.name}`"
41 + loading="lazy"
42 + />
31 </div> 43 </div>
32 <div class="item-caption"> 44 <div class="item-caption">
33 <div class="item-role">{{ item.role }}</div> 45 <div class="item-role">{{ item.role }}</div>
34 - <div class="item-name small" v-html="formatNameWithSuperscript(item.name)"></div> 46 + <div class="item-name small">
47 + <sup class="name-sup">上</sup>{{ item.name.charAt(0) }}<sup class="name-sup">下</sup>{{ item.name.slice(1) }}
48 + </div>
49 + </div>
50 + </div>
51 + </section>
52 +
53 + <!-- 一行两个 Item(双列) -->
54 + <section class="grid-two grid-two--spaced">
55 + <div
56 + class="item-card small"
57 + v-for="(item, i) in gridItems2"
58 + :key="`grid2-${i}`"
59 + @click="goDetail(item)"
60 + >
61 + <div class="item-image">
62 + <img
63 + :src="item.image"
64 + :alt="`${item.role} ${item.name}`"
65 + :aria-label="`${item.role}:${item.name}`"
66 + loading="lazy"
67 + />
68 + </div>
69 + <div class="item-caption">
70 + <div class="item-role">{{ item.role }}</div>
71 + <div class="item-name small">
72 + <sup class="name-sup">上</sup>{{ item.name.charAt(0) }}<sup class="name-sup">下</sup>{{ item.name.slice(1) }}
73 + </div>
35 </div> 74 </div>
36 </div> 75 </div>
37 </section> 76 </section>
...@@ -56,44 +95,60 @@ const singleItems = ref([]) ...@@ -56,44 +95,60 @@ const singleItems = ref([])
56 // 七证 95 // 七证
57 const gridItems = ref([]) 96 const gridItems = ref([])
58 97
98 +// 引礼师
99 +const gridItems2 = ref([])
100 +
59 const goDetail = (item) => { 101 const goDetail = (item) => {
60 router.push(`/masters/${item.id}`) 102 router.push(`/masters/${item.id}`)
61 } 103 }
62 104
105 +const pid = ref(router.currentRoute.value.query.pid)
106 +
63 /** 107 /**
64 - * 为name字段的第一个文字添加上标效果 108 + * 安全处理图片 URL,添加处理参数并处理空值
65 - * @param {string} name - 原始姓名 109 + * @param {string} photo - 原始图片 URL
66 - * @returns {string} - 带上标的HTML字符串 110 + * @returns {string} - 处理后的图片 URL
67 */ 111 */
68 -const formatNameWithSuperscript = (name) => { 112 +const processImageUrl = (photo) => {
69 - if (!name || name.length === 0) return name 113 + if (!photo) return '/assets/default-avatar.png'
70 - 114 + return `${photo}?imageMogr2/thumbnail/400x/strip/quality/70`
71 - const firstChar = name.charAt(0)
72 - const restChars = name.slice(1)
73 -
74 - return `<sup style="font-size: 0.6rem;">上</sup>${firstChar}<sup style="font-size: 0.6rem;">下</sup>${restChars}`
75 } 115 }
76 116
77 -const pid = ref(router.currentRoute.value.query.pid) 117 +/**
118 + * 安全处理大师数据项
119 + * @param {object} item - API 返回的数据项
120 + * @returns {object} - 处理后的数据项
121 + */
122 +const processMasterItem = (item) => ({
123 + id: item?.id || '',
124 + role: item?.post_excerpt || '',
125 + name: item?.post_title || '未知',
126 + image: processImageUrl(item?.photo)
127 +})
78 128
79 onMounted(async () => { 129 onMounted(async () => {
130 + try {
80 // 调用接口获取三师七证数据 131 // 调用接口获取三师七证数据
81 - const { code, list } = await getSSQZAPI({ pid: pid.value }); 132 + const { code, list } = await getSSQZAPI({ pid: pid.value })
82 - if (code) { 133 +
83 - const singleItemsData = list[0].list; 134 + // 验证响应数据
84 - const gridItemsData = list[1].list; 135 + if (!code || !list || !Array.isArray(list)) {
85 - singleItems.value = singleItemsData.map(item => ({ 136 + console.error('Invalid API response: missing or invalid data')
86 - id: item.id, 137 + return
87 - role: item.post_excerpt, 138 + }
88 - name: item.post_title, 139 +
89 - image: item.photo + '?imageMogr2/thumbnail/400x/strip/quality/70' 140 + // 安全地获取数据列表,使用可选链和默认值
90 - })) 141 + const singleItemsData = list[0]?.list || []
91 - gridItems.value = gridItemsData.map(item => ({ 142 + const gridItemsData = list[1]?.list || []
92 - id: item.id, 143 + const gridItems2Data = list[2]?.list || []
93 - role: item.post_excerpt, 144 +
94 - name: item.post_title, 145 + // 处理数据
95 - image: item.photo + '?imageMogr2/thumbnail/400x/strip/quality/70' 146 + singleItems.value = singleItemsData.map(processMasterItem)
96 - })) 147 + gridItems.value = gridItemsData.map(processMasterItem)
148 + gridItems2.value = gridItems2Data.map(processMasterItem)
149 + } catch (error) {
150 + console.error('Failed to fetch masters data:', error)
151 + // 可以在这里添加用户提示,例如使用 showFailToast
97 } 152 }
98 }) 153 })
99 </script> 154 </script>
...@@ -120,6 +175,10 @@ onMounted(async () => { ...@@ -120,6 +175,10 @@ onMounted(async () => {
120 gap: 0.75rem; 175 gap: 0.75rem;
121 } 176 }
122 177
178 +.grid-two--spaced {
179 + margin-top: 1rem;
180 +}
181 +
123 /* 卡片 */ 182 /* 卡片 */
124 .item-card { 183 .item-card {
125 position: relative; 184 position: relative;
...@@ -179,6 +238,11 @@ onMounted(async () => { ...@@ -179,6 +238,11 @@ onMounted(async () => {
179 font-size: 1.25rem; 238 font-size: 1.25rem;
180 } 239 }
181 240
241 +/* 姓名上标样式 */
242 +.name-sup {
243 + font-size: 0.6em;
244 +}
245 +
182 246
183 /* 响应式调整 */ 247 /* 响应式调整 */
184 @media (max-width: 48rem) { 248 @media (max-width: 48rem) {
......