Skip to content

Commit 8a306eb

Browse files
committed
✨ feat: 支持分享战绩
1 parent 640e329 commit 8a306eb

File tree

4 files changed

+183
-75
lines changed

4 files changed

+183
-75
lines changed

game/backend/src/entities/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ export class EntitySubscriber implements EntitySubscriberInterface {
3131
}
3232
}
3333

34-
3534
async function getGameDatas() {
3635
const metadata = getMetadataArgsStorage();
3736
const Games = await gameLoaded;
@@ -53,7 +52,7 @@ const initDataSource = (async () => {
5352
logging: false,
5453
...utils.config.database,
5554
synchronize: true,
56-
entities: [User, Log, Record, PlayerStats, Manage, ...await getGameDatas(), ...(utils.config.persistence?.driver == 'mysql' ? [RoomSQL] : [])],
55+
entities: [User, UserBind, Log, Record, PlayerStats, Manage, ...await getGameDatas(), ...(utils.config.persistence?.driver == 'mysql' ? [RoomSQL] : [])],
5756
migrations: [],
5857
subscribers: [],
5958
charset: "utf8mb4_unicode_ci"

game/backend/src/routes/embed.ts

Lines changed: 99 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,29 @@ import FishPi from "fishpi";
1616

1717
const router = Router();
1818

19-
router.get('/:username/:game', async (req: Request, res: Response) => {
19+
function valueToColor(value: string, defaultValue: string): string {
20+
if (!value) return defaultValue;
21+
if (value.match(/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/)) {
22+
return '#' + value;
23+
} else {
24+
return value;
25+
}
26+
}
27+
28+
router.get('/state/:username/:game.svg', async (req: Request, res: Response) => {
2029
const { username, game } = req.params;
2130

31+
// Extract color variables from query params
32+
const contentColor = valueToColor(req.query['content'] as string, '#000');
33+
const bgColor = valueToColor(req.query['bg'] as string, '#f3f4f6');
34+
const winColor = valueToColor(req.query['win'] as string, '#16a34a');
35+
const drawColor = valueToColor(req.query['draw'] as string, '#ca8a04');
36+
const lossColor = valueToColor(req.query['loss'] as string, '#dc2626');
37+
const scoreColor = valueToColor(req.query['score'] as string, '#06b6d4');
38+
const progressBg = valueToColor(req.query['progress-bg'] as string, '#e5e7eb');
39+
const textGray = valueToColor(req.query['text-gray'] as string, '#6b7280');
40+
const errorColor = valueToColor(req.query['error'] as string, 'red');
41+
2242
try {
2343
const stats = await getPlayerStat(username, game);
2444
const gameName = Games[game]?.name || game;
@@ -28,84 +48,92 @@ router.get('/:username/:game', async (req: Request, res: Response) => {
2848
const drawPercent = stats.total > 0 ? (stats.draws / stats.total * 100) : 0;
2949
const lossPercent = stats.total > 0 ? (stats.losses / stats.total * 100) : 0;
3050
htmlContent = `
31-
<style>
32-
.card { background-color: #f3f4f6; border-radius: 8px; padding: 16px; font-family: system-ui, -apple-system, sans-serif; box-sizing: border-box; }
33-
.header { display: flex; justify-content: space-between; align-items: baseline; }
34-
.game-name { font-size: 14px; font-weight: bold; margin-bottom: 4px; }
35-
.score-label { font-size: 12px; opacity: 0.6; }
36-
.stats { display: flex; justify-content: space-between; font-size: 12px; }
37-
.win { color: #16a34a; }
38-
.draw { color: #ca8a04; }
39-
.loss { color: #dc2626; }
40-
.progress-bar { width: 100%; background-color: #e5e7eb; border-radius: 9999px; height: 8px; margin-top: 8px; overflow: hidden; display: flex; }
41-
.progress-win { background-color: #16a34a; height: 100%; }
42-
.progress-draw { background-color: #ca8a04; height: 100%; }
43-
.progress-loss { background-color: #dc2626; height: 100%; }
44-
.score-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 8px 0; }
45-
.score { font-size: 30px; font-weight: 900; color: #06b6d4; }
46-
.bottom { text-align: right; font-size: 12px; margin-top: 4px; opacity: 0.6; padding-right: 4px; }
47-
</style>
48-
<div class="card">
49-
<header class="header">
50-
<div class="game-name">${gameName}</div>
51-
${stats.score ? '<div class="score-label">最高得分</div>' : ''}
52-
</header>
53-
${!stats.score ? `
54-
<div class="stats">
55-
<span class="win">胜: ${stats.wins}</span>
56-
<span class="draw">平: ${stats.draws}</span>
57-
<span class="loss">负: ${stats.losses}</span>
58-
</div>
59-
<div class="progress-bar">
60-
<div class="progress-win" style="width: ${winPercent}%;"></div>
61-
<div class="progress-draw" style="width: ${drawPercent}%;"></div>
62-
<div class="progress-loss" style="width: ${lossPercent}%;"></div>
63-
</div>
64-
` : `
65-
<div class="score-container">
66-
<div class="score">${stats.score}</div>
67-
</div>
68-
`}
69-
<div class="bottom">游玩次数:${stats.total}</div>
51+
<style>
52+
:root {
53+
--bg-color: ${bgColor};
54+
--win-color: ${winColor};
55+
--draw-color: ${drawColor};
56+
--loss-color: ${lossColor};
57+
--score-color: ${scoreColor};
58+
--progress-bg: ${progressBg};
59+
--content-color: ${contentColor};
60+
--text-gray: ${textGray};
61+
}
62+
.card { color: var(--content-color); background-color: var(--bg-color); border-radius: 8px; padding: 16px; font-family: system-ui, -apple-system, sans-serif; box-sizing: border-box; }
63+
.header { display: flex; justify-content: space-between; align-items: baseline; }
64+
.game-name { font-size: 14px; font-weight: bold; margin-bottom: 4px; opacity: 0.6; }
65+
.score-label { font-size: 12px; opacity: 0.6; }
66+
.stats { display: flex; justify-content: space-between; font-size: 12px; }
67+
.win { color: var(--win-color); }
68+
.draw { color: var(--draw-color); }
69+
.loss { color: var(--loss-color); }
70+
.progress-bar { width: 100%; background-color: var(--progress-bg); border-radius: 9999px; height: 8px; margin-top: 8px; overflow: hidden; display: flex; }
71+
.progress-win { background-color: var(--win-color); height: 100%; }
72+
.progress-draw { background-color: var(--draw-color); height: 100%; }
73+
.progress-loss { background-color: var(--loss-color); height: 100%; }
74+
.score-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 8px 0; }
75+
.score { font-size: 30px; font-weight: 900; color: var(--score-color); }
76+
.bottom { text-align: right; font-size: 12px; margin-top: 4px; opacity: 0.8; padding-right: 4px; color: var(--text-gray); }
77+
</style>
78+
<div class="card">
79+
<header class="header">
80+
<div class="game-name">${gameName}</div>${stats.score ? `
81+
<div class="score-label">最高得分</div>` : ''}
82+
</header>${!stats.score ? `
83+
<div class="stats">
84+
<span class="win">胜: ${stats.wins}</span>
85+
<span class="draw">平: ${stats.draws}</span>
86+
<span class="loss">负: ${stats.losses}</span>
7087
</div>
71-
<div class="bottom">@${username}</div>
72-
`;
88+
<div class="progress-bar">
89+
<div class="progress-win" style="width: ${winPercent}%;"></div>
90+
<div class="progress-draw" style="width: ${drawPercent}%;"></div>
91+
<div class="progress-loss" style="width: ${lossPercent}%;"></div>
92+
</div>` : `
93+
<div class="score-container">
94+
<div class="score">${stats.score}</div>
95+
</div>`}
96+
<div class="bottom">游玩次数:${stats.total}</div>
97+
</div>
98+
<div class="bottom">@${username}</div>
99+
`;
73100
} else {
74101
htmlContent = `
75-
<style>
76-
.no-data { background-color: #f3f4f6; border-radius: 8px; padding: 16px; font-family: system-ui, -apple-system, sans-serif; box-sizing: border-box; display: flex; align-items: center; justify-content: center; }
77-
.no-data-text { text-align: center; color: #6b7280; }
78-
</style>
79-
<div class="no-data">
80-
<div class="no-data-text">未找到 ${username}${gameName} 中的战绩</div>
81-
</div>
82-
`;
83-
}
84-
const svg = `
85-
<svg width="400" height="168" xmlns="http://www.w3.org/2000/svg">
86-
<foreignObject x="0" y="0" width="400" height="168">
87-
<html xmlns="http://www.w3.org/1999/xhtml">
88-
${htmlContent}
89-
</html>
90-
</foreignObject>
91-
</svg>
102+
<style>
103+
:root {
104+
--bg-color: ${bgColor};
105+
--text-gray: ${textGray};
106+
}
107+
.no-data { background-color: var(--bg-color); border-radius: 8px; padding: 16px; width: 368px; height: 168px; font-family: system-ui, -apple-system, sans-serif; box-sizing: border-box; display: flex; align-items: center; justify-content: center; }
108+
.no-data-text { text-align: center; color: var(--text-gray); }
109+
</style>
110+
<div class="no-data">
111+
<div class="no-data-text">未找到 ${username}${gameName} 中的战绩</div>
112+
</div>
92113
`;
114+
}
115+
const svg = `<svg width="360" height="168" xmlns="http://www.w3.org/2000/svg">
116+
<foreignObject x="0" y="0" width="360" height="168">
117+
<html xmlns="http://www.w3.org/1999/xhtml">${htmlContent}</html>
118+
</foreignObject>
119+
</svg>`;
93120
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
94121
res.send(svg);
95122
} catch (error) {
96123
console.error(error);
97-
const errorSvg = `
98-
<svg width="400" height="168" xmlns="http://www.w3.org/2000/svg">
99-
<foreignObject x="0" y="0" width="400" height="168">
100-
<html xmlns="http://www.w3.org/1999/xhtml">
101-
<style>
102-
.error { color: red; text-align: center; padding: 20px; width: 400px; height: 168px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; }
103-
</style>
104-
<div class="error">服务器错误</div>
105-
</html>
106-
</foreignObject>
107-
</svg>
108-
`;
124+
const errorSvg = `<svg width="360" height="168" xmlns="http://www.w3.org/2000/svg">
125+
<foreignObject x="0" y="0" width="360" height="168">
126+
<html xmlns="http://www.w3.org/1999/xhtml">
127+
<style>
128+
:root {
129+
--error-color: ${errorColor};
130+
}
131+
.error { color: var(--error-color); text-align: center; padding: 20px; width: 360px; height: 168px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; }
132+
</style>
133+
<div class="error">服务器错误</div>
134+
</html>
135+
</foreignObject>
136+
</svg>`;
109137
res.status(500).send(errorSvg);
110138
}
111139
});

game/frontend/src/utils/index.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,70 @@ export async function sha256(message: string): Promise<string> {
123123
// 转为十六进制字符串
124124
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
125125
return hashHex;
126+
}
127+
128+
// 获取 Diasiyui 当前主题的颜色,包含 primary,secondary,accent,success,warning,error, info 等
129+
export function getThemeColor() {
130+
const styles = getComputedStyle(document.documentElement);
131+
const colorMap: Record<string, string> = {};
132+
[
133+
"primary",
134+
"primary-content",
135+
"secondary",
136+
"secondary-content",
137+
"accent",
138+
"accent-content",
139+
"neutral",
140+
"neutral-content",
141+
"base-100",
142+
"base-200",
143+
"base-300",
144+
"base-content",
145+
"info",
146+
"info-content",
147+
"success",
148+
"success-content",
149+
"warning",
150+
"warning-content",
151+
"error",
152+
"error-content"
153+
].forEach(color => {
154+
colorMap[color] = styles.getPropertyValue(`--color-${color}`).trim();
155+
});
156+
return colorMap;
157+
}
158+
159+
export function getStateSvgUrl(username: string, game: string) {
160+
let url = `${location.origin}/embed/state/${encodeURIComponent(username)}/${encodeURIComponent(game)}.svg?`;
161+
const theme = getThemeColor();
162+
url += 'content=' + encodeURI(theme['base-content']) + '&';
163+
url += 'bg=' + encodeURI(theme['base-300']) + '&';
164+
url += 'win=' + encodeURI(theme['success']) + '&';
165+
url += 'draw=' + encodeURI(theme['warning']) + '&';
166+
url += 'loss=' + encodeURI(theme['error']) + '&';
167+
url += 'score=' + encodeURI(theme['info']);
168+
return url;
169+
}
170+
171+
export async function copySvgUrlAsImage(svgUrl: string, name = '') {
172+
try {
173+
// 方案1: 同时复制图片数据和 HTML(推荐)
174+
const response = await fetch(svgUrl);
175+
const blob = await response.blob();
176+
177+
// 创建包含图片引用的 HTML
178+
const html = `<img src="${svgUrl}" alt="${name}" />`;
179+
180+
await navigator.clipboard.write([
181+
new ClipboardItem({
182+
'text/html': new Blob([html], { type: 'text/html' }),
183+
'image/svg+xml': blob,
184+
'text/plain': new Blob([svgUrl], { type: 'text/plain' })
185+
})
186+
]);
187+
188+
return true;
189+
} catch (err) {
190+
return false;
191+
}
126192
}

game/frontend/src/views/Profile.vue

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
<script lang="ts" setup>
2-
import { api, User } from '@/api';
2+
import { api, GameStats, User } from '@/api';
33
import { ref, watch, computed } from 'vue';
44
import { useRoute } from 'vue-router';
55
import Icon from '@/components/common/Icon.vue';
66
import { useGameStore } from '@/stores/game';
77
import { getComponent } from '@/components';
8+
import { copySvgUrlAsImage, getStateSvgUrl } from '@/utils';
9+
import msg from '@/components/msg';
810
911
const user = ref<User>();
1012
const route = useRoute();
@@ -91,6 +93,14 @@
9193
const hasReplayComponent = (type: string) => {
9294
return !!getComponent(type, 'Replay')
9395
}
96+
97+
async function onShare(stat: GameStats) {
98+
if (await copySvgUrlAsImage(getStateSvgUrl(user.value!.username, stat.type), `${user.value!.nickname} 的 ${gameStore.games[stat.type]?.name || stat.type} 战绩`)) {
99+
msg.success('战绩已复制,快去分享吧!');
100+
} else {
101+
msg.error('复制失败!');
102+
}
103+
}
94104
</script>
95105

96106
<template>
@@ -166,7 +176,12 @@
166176
<div class="text-3xl font-black text-info">{{ stat.score }}</div>
167177
</div>
168178
</template>
169-
<div class="text-right text-xs mt-1 opacity-60">游玩次数:{{ stat.total }}</div>
179+
<div class="flex items-center justify-between text-xs mt-1 opacity-60">
180+
<button class="btn btn-ghost btn-xs tooltip" :data-tip="`复制${gameStore.games[stat.type]?.name || stat.type}战绩`" @click="onShare(stat)">
181+
<Icon icon="tabler:copy" />
182+
</button>
183+
<span>游玩次数:{{ stat.total }}</span>
184+
</div>
170185
</div>
171186
</div>
172187
<div v-else class="flex items-center justify-center h-32 bg-base-200 rounded-lg border-2 border-dashed border-base-300">

0 commit comments

Comments
 (0)