feat(hud): 添加击杀信息生成器并优化页面加载体验
- 新增击杀信息生成器页面,支持CS2和CStrike两种游戏样式 - 引入字体文件用于击杀信息渲染 - 添加多种骨架屏组件(AuthSkeleton、PageSkeleton等)改善加载体验 - 更新依赖包,新增html-to-image、react-icons等库 - 优化现有组件的样式和导入路径 - 统一使用@别名导入模块
This commit is contained in:
@@ -13,17 +13,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.4.8",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@heroui/react": "^3.0.0-beta.8",
|
||||
"@heroui/styles": "^3.0.0-beta.8",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"ahooks": "^3.9.6",
|
||||
"better-auth": "^1.5.4",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lucide-react": "^0.577.0",
|
||||
"postcss": "^8.5.8",
|
||||
"primeicons": "^7.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"swr": "^2.4.1",
|
||||
|
||||
9
web/src/app/hud.tsx
Normal file
9
web/src/app/hud.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export default function HudLayout() {
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-start min-h-screen gap-20 py-24 mx-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
3
web/src/app/hud/crosshair/index.tsx
Normal file
3
web/src/app/hud/crosshair/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Crosshair() {
|
||||
return <div>准星生成</div>
|
||||
}
|
||||
68
web/src/app/hud/deathmsg/canvas.ts
Normal file
68
web/src/app/hud/deathmsg/canvas.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { toPng } from 'html-to-image'
|
||||
|
||||
export async function Canvas2Image(e: HTMLElement, name: string, dpi: number) {
|
||||
// 滚动条置顶解决生成图片不全的问题
|
||||
window.scrollY = 0
|
||||
document.documentElement.scrollTop = 0
|
||||
document.documentElement.scrollLeft = 0
|
||||
document.body.scrollTop = 0
|
||||
|
||||
// 保存原始样式
|
||||
const originalPosition = e.style.position
|
||||
const originalTop = e.style.top
|
||||
const originalBottom = e.style.bottom
|
||||
const originalLeft = e.style.left
|
||||
const originalRight = e.style.right
|
||||
const originalVisibility = e.style.visibility
|
||||
|
||||
try {
|
||||
// 临时调整元素位置,确保在视口内可见
|
||||
e.style.position = 'relative'
|
||||
e.style.top = '0'
|
||||
e.style.bottom = 'auto'
|
||||
e.style.left = '0'
|
||||
e.style.right = 'auto'
|
||||
e.style.visibility = 'visible'
|
||||
|
||||
// 等待样式应用
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
const dataUrl = await toPng(e, {
|
||||
backgroundColor: 'transparent', // 透明背景
|
||||
pixelRatio: dpi,
|
||||
quality: 1.0,
|
||||
style: {
|
||||
letterSpacing: 'normal',
|
||||
lineHeight: 'normal',
|
||||
},
|
||||
})
|
||||
|
||||
if (dataUrl !== '') {
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.setAttribute('download', name + '.png')
|
||||
link.style.display = 'none'
|
||||
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
// 等待一小段时间确保下载已触发,然后再移除元素
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 确保元素存在且仍在 DOM 中再移除
|
||||
if (link.parentNode) {
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成 PNG 失败:', error)
|
||||
} finally {
|
||||
// 恢复原始样式
|
||||
e.style.position = originalPosition
|
||||
e.style.top = originalTop
|
||||
e.style.bottom = originalBottom
|
||||
e.style.left = originalLeft
|
||||
e.style.right = originalRight
|
||||
e.style.visibility = originalVisibility
|
||||
}
|
||||
}
|
||||
113
web/src/app/hud/deathmsg/dm.css
Normal file
113
web/src/app/hud/deathmsg/dm.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* 参考用 */
|
||||
@font-face {
|
||||
font-family: 'Stratum2';
|
||||
src: url('@/assets/font/Stratum2.ttf');
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
@apply px-3 py-1 rounded-lg outline-none;
|
||||
}
|
||||
|
||||
.dn-input {
|
||||
@apply px-3 py-1 rounded-lg outline-none bg-gray-100 w-full;
|
||||
}
|
||||
|
||||
.dn-item {
|
||||
@apply flex flex-col gap-3 bg-white rounded-lg p-3;
|
||||
}
|
||||
|
||||
.dn-camp {
|
||||
@apply bg-gray-100 rounded w-12;
|
||||
}
|
||||
|
||||
.dn-fix-icon {
|
||||
@apply rounded p-1 w-8 transition duration-200 hover:bg-gray-300 active:scale-95;
|
||||
}
|
||||
|
||||
/* .dn-button { */
|
||||
/* } */
|
||||
|
||||
#OutputDiv {
|
||||
clear: both;
|
||||
/*width: 1920px;*/
|
||||
/*height: 1080px;*/
|
||||
/*position: fixed;*/
|
||||
/*float: end;*/
|
||||
margin: 30px auto;
|
||||
/*background: pink; !*debug用的颜色*!*/
|
||||
background: rgba(0, 0, 0, 0);
|
||||
/**/
|
||||
/*font-weight: bold;*/
|
||||
/*font-family: 'Stratum2';*/
|
||||
/* src: url("../assets/font/Stratum2.ttf") format('truetype'); */
|
||||
}
|
||||
|
||||
.deathNotice {
|
||||
font-family: 'Stratum2', 'Arial Unicode MS', 'Helvetica Neue', 'Helvetica', 'PingFang SC', 'Sarasa Gothic SC', 'Source Han Sans', 'Noto Sans SC',
|
||||
'Alibaba Puhuiti 2', 'Alibaba Puhuiti', Avenir, Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
font-weight: bold;
|
||||
width: max-content;
|
||||
/*float: right;*/
|
||||
right: 0;
|
||||
padding: 6px 12px 6px 12px;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: ease-out;
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
/*box-shadow: inset #e10000e6 0px 0px 1px;*/
|
||||
/*border: 2px solid #e10000;*/
|
||||
/*border: 2px outset rgba(0,0,0,0.44);*/
|
||||
}
|
||||
|
||||
.dn-gen {
|
||||
line-height: 0.5 !important;
|
||||
}
|
||||
|
||||
.CT {
|
||||
color: rgb(111, 156, 230);
|
||||
}
|
||||
|
||||
.T {
|
||||
color: rgb(234, 190, 84);
|
||||
}
|
||||
|
||||
.weapon {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
height: 23px;
|
||||
vertical-align: middle;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.prefix {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.attacker {
|
||||
padding-right: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.victim {
|
||||
padding-left: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.DispRedBorder {
|
||||
/*height: 30px;*/
|
||||
border: 2px solid #e10000;
|
||||
padding: 4px 10px 4px 10px;
|
||||
}
|
||||
|
||||
.dn-transparent {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
554
web/src/app/hud/deathmsg/dmsg.ts
Normal file
554
web/src/app/hud/deathmsg/dmsg.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
export type DeathMsg = {
|
||||
attacker: string
|
||||
attackerCamp: Camp
|
||||
victim: string
|
||||
victimCamp: Camp
|
||||
weapon: Weapon
|
||||
prefixIcons: prefixIcon[]
|
||||
suffixIcons: suffixIcon[]
|
||||
redBorder: boolean
|
||||
hide?: boolean
|
||||
}
|
||||
|
||||
export const DefaultDeathMsgs: DeathMsg[] = [
|
||||
{
|
||||
attacker: 'Attacker',
|
||||
attackerCamp: 'T',
|
||||
victim: 'Victim',
|
||||
victimCamp: 'CT',
|
||||
weapon: 'ak47',
|
||||
prefixIcons: ['blind_kill'],
|
||||
suffixIcons: ['headshot'],
|
||||
redBorder: false,
|
||||
hide: false,
|
||||
},
|
||||
{
|
||||
attacker: '中文字体样式.gg',
|
||||
attackerCamp: 'CT',
|
||||
victim: 'Purp1e',
|
||||
victimCamp: 'T',
|
||||
weapon: 'awp',
|
||||
prefixIcons: ['revenge', 'blind_kill'],
|
||||
suffixIcons: [],
|
||||
redBorder: true,
|
||||
hide: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const CStrikeDefaultDeathMsgs: DeathMsg[] = [
|
||||
{
|
||||
attacker: 'Attacker',
|
||||
attackerCamp: 'T',
|
||||
victim: 'Victim',
|
||||
victimCamp: 'CT',
|
||||
weapon: 'ak47',
|
||||
prefixIcons: [],
|
||||
suffixIcons: ['headshot'],
|
||||
redBorder: false,
|
||||
hide: false,
|
||||
},
|
||||
{
|
||||
attacker: 'CS1.6样式',
|
||||
attackerCamp: 'CT',
|
||||
victim: 'Purp1e',
|
||||
victimCamp: 'T',
|
||||
weapon: 'awp',
|
||||
prefixIcons: [],
|
||||
suffixIcons: [],
|
||||
redBorder: true,
|
||||
hide: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const DefaultDeathMsg = DefaultDeathMsgs[0]
|
||||
export const CStrikeDefaultDeathMsg = CStrikeDefaultDeathMsgs[0]
|
||||
|
||||
export type Camp = 'CT' | 'T' | ''
|
||||
|
||||
export const CampValues: Camp[] = ['CT', 'T']
|
||||
|
||||
export type CStrikePrefixIcon = ''
|
||||
export type CS2PrefixIcon = 'revenge' | 'domination' | 'blind_kill' | ''
|
||||
export type prefixIcon = CStrikePrefixIcon | CS2PrefixIcon
|
||||
|
||||
export const CStrikePrefixIconValues: CStrikePrefixIcon[] = []
|
||||
export const CS2PrefixIconValues: CS2PrefixIcon[] = ['revenge', 'domination', 'blind_kill']
|
||||
|
||||
export type CStrikeSuffixIcon = 'headshot' | ''
|
||||
export type CS2SuffixIcon = 'noscope' | 'jumpkill' | 'throughsmoke' | 'penetrate' | 'headshot' | 'suicide' | 'kill360' | ''
|
||||
export type suffixIcon = CStrikeSuffixIcon | CS2SuffixIcon
|
||||
|
||||
export const CStrikeSuffixIconValues: CStrikeSuffixIcon[] = ['headshot']
|
||||
export const CS2SuffixIconValues: CS2SuffixIcon[] = ['noscope', 'jumpkill', 'throughsmoke', 'penetrate', 'headshot', 'suicide', 'kill360']
|
||||
|
||||
export type CStrikeWeapon =
|
||||
| '228-compact'
|
||||
| '550-commando'
|
||||
| 'ak47'
|
||||
| 'aug'
|
||||
| 'awp'
|
||||
| 'cs-grenade'
|
||||
| 'd3_au-1'
|
||||
| 'deagle'
|
||||
| 'dual-elites'
|
||||
| 'famas-1'
|
||||
| 'famas'
|
||||
| 'flare'
|
||||
| 'gauge-super'
|
||||
| 'glock'
|
||||
| 'he-grenade'
|
||||
| 'idf-defender'
|
||||
| 'knife'
|
||||
| 'krieg-552'
|
||||
| 'law-grenade'
|
||||
| 'm249-2'
|
||||
| 'm249'
|
||||
| 'mac10-1'
|
||||
| 'mac10'
|
||||
| 'melee'
|
||||
| 'mp5'
|
||||
| 'nade'
|
||||
| 'p90'
|
||||
| 'rifle'
|
||||
| 'scout'
|
||||
| 'sg552'
|
||||
| 'suicide'
|
||||
| 'train'
|
||||
| 'ump45'
|
||||
| 'usp-silencer'
|
||||
| 'vault'
|
||||
| 'wtf'
|
||||
| 'yg1265'
|
||||
| ''
|
||||
export type CS2Weapon =
|
||||
| 'ak47'
|
||||
| 'ammobox'
|
||||
| 'ammobox_threepack'
|
||||
| 'armor'
|
||||
| 'armor_helmet'
|
||||
| 'assaultsuit_helmet_only'
|
||||
| 'assaultsuit'
|
||||
| 'aug'
|
||||
| 'awp'
|
||||
| 'axe'
|
||||
| 'bayonet'
|
||||
| 'bizon'
|
||||
| 'breachcharge'
|
||||
| 'breachcharge_projectile'
|
||||
| 'bumpmine'
|
||||
| 'c4'
|
||||
| 'clothing_hands'
|
||||
| 'controldrone'
|
||||
| 'customplayer'
|
||||
| 'cz75a'
|
||||
| 'deagle'
|
||||
| 'decoy'
|
||||
| 'defuser'
|
||||
| 'disconnect'
|
||||
| 'diversion'
|
||||
| 'dronegun'
|
||||
| 'elite'
|
||||
| 'famas'
|
||||
| 'firebomb'
|
||||
| 'fists'
|
||||
| 'fiveseven'
|
||||
| 'flair0'
|
||||
| 'flashbang'
|
||||
| 'flashbang_assist'
|
||||
| 'frag_grenade'
|
||||
| 'g3sg1'
|
||||
| 'galilar'
|
||||
| 'glock'
|
||||
| 'grenadepack'
|
||||
| 'grenadepack2'
|
||||
| 'hammer'
|
||||
| 'healthshot'
|
||||
| 'heavy_armor'
|
||||
| 'hegrenade'
|
||||
| 'helmet'
|
||||
| 'hkp2000'
|
||||
| 'incgrenade'
|
||||
| 'inferno'
|
||||
| 'kevlar'
|
||||
| 'knife'
|
||||
| 'knife_bowie'
|
||||
| 'knife_butterfly'
|
||||
| 'knife_canis'
|
||||
| 'knife_cord'
|
||||
| 'knife_css'
|
||||
| 'knife_falchion'
|
||||
| 'knife_flip'
|
||||
| 'knife_gut'
|
||||
| 'knife_gypsy_jackknife'
|
||||
| 'knife_karambit'
|
||||
| 'knife_kukri'
|
||||
| 'knife_m9_bayonet'
|
||||
| 'knife_push'
|
||||
| 'knife_skeleton'
|
||||
| 'knife_stiletto'
|
||||
| 'knife_survival_bowie'
|
||||
| 'knife_t'
|
||||
| 'knife_tactical'
|
||||
| 'knife_twinblade'
|
||||
| 'knife_ursus'
|
||||
| 'knife_widowmaker'
|
||||
| 'knifegg'
|
||||
| 'm4a1'
|
||||
| 'm4a1_silencer'
|
||||
| 'm4a1_silencer_off'
|
||||
| 'm249'
|
||||
| 'mac10'
|
||||
| 'mag7'
|
||||
| 'melee'
|
||||
| 'molotov'
|
||||
| 'movelinear'
|
||||
| 'mp5sd'
|
||||
| 'mp7'
|
||||
| 'mp9'
|
||||
| 'negev'
|
||||
| 'nova'
|
||||
| 'p90'
|
||||
| 'p250'
|
||||
| 'p2000'
|
||||
| 'planted_c4_survival'
|
||||
| 'planted_c4'
|
||||
| 'prop_exploding_barrel'
|
||||
| 'radarjammer'
|
||||
| 'revolver'
|
||||
| 'sawedoff'
|
||||
| 'scar20'
|
||||
| 'sg556'
|
||||
| 'shield'
|
||||
| 'smokegrenade'
|
||||
| 'snowball'
|
||||
| 'spanner'
|
||||
| 'spray0'
|
||||
| 'ssg08'
|
||||
| 'stomp_damage'
|
||||
| 'tablet'
|
||||
| 'tagrenade'
|
||||
| 'taser'
|
||||
| 'tec9'
|
||||
| 'tripwirefire_projectile'
|
||||
| 'tripwirefire'
|
||||
| 'ump45'
|
||||
| 'usp_silencer'
|
||||
| 'usp_silencer_off'
|
||||
| 'xm1014'
|
||||
| 'zone_repulsor'
|
||||
| ''
|
||||
export type Weapon = CStrikeWeapon | CS2Weapon
|
||||
|
||||
export const CStrikeWeaponValues: CStrikeWeapon[] = [
|
||||
'228-compact',
|
||||
'550-commando',
|
||||
'ak47',
|
||||
'aug',
|
||||
'awp',
|
||||
'cs-grenade',
|
||||
'd3_au-1',
|
||||
'deagle',
|
||||
'dual-elites',
|
||||
'famas-1',
|
||||
'famas',
|
||||
'flare',
|
||||
'gauge-super',
|
||||
'glock',
|
||||
'he-grenade',
|
||||
'idf-defender',
|
||||
'knife',
|
||||
'krieg-552',
|
||||
'law-grenade',
|
||||
'm249-2',
|
||||
'm249',
|
||||
'mac10-1',
|
||||
'mac10',
|
||||
'melee',
|
||||
'mp5',
|
||||
'nade',
|
||||
'p90',
|
||||
'rifle',
|
||||
'scout',
|
||||
'sg552',
|
||||
'suicide',
|
||||
'train',
|
||||
'ump45',
|
||||
'usp-silencer',
|
||||
'vault',
|
||||
'wtf',
|
||||
'yg1265',
|
||||
]
|
||||
export const CS2WeaponValues: CS2Weapon[] = [
|
||||
'ak47',
|
||||
'ammobox',
|
||||
'ammobox_threepack',
|
||||
'armor',
|
||||
'armor_helmet',
|
||||
'assaultsuit_helmet_only',
|
||||
'assaultsuit',
|
||||
'aug',
|
||||
'awp',
|
||||
'axe',
|
||||
'bayonet',
|
||||
'bizon',
|
||||
'breachcharge',
|
||||
'breachcharge_projectile',
|
||||
'bumpmine',
|
||||
'c4',
|
||||
'clothing_hands',
|
||||
'controldrone',
|
||||
'customplayer',
|
||||
'cz75a',
|
||||
'deagle',
|
||||
'decoy',
|
||||
'defuser',
|
||||
'disconnect',
|
||||
'diversion',
|
||||
'dronegun',
|
||||
'elite',
|
||||
'famas',
|
||||
'firebomb',
|
||||
'fists',
|
||||
'fiveseven',
|
||||
'flair0',
|
||||
'flashbang',
|
||||
'flashbang_assist',
|
||||
'frag_grenade',
|
||||
'g3sg1',
|
||||
'galilar',
|
||||
'glock',
|
||||
'grenadepack',
|
||||
'grenadepack2',
|
||||
'hammer',
|
||||
'healthshot',
|
||||
'heavy_armor',
|
||||
'hegrenade',
|
||||
'helmet',
|
||||
'hkp2000',
|
||||
'incgrenade',
|
||||
'inferno',
|
||||
'kevlar',
|
||||
'knife',
|
||||
'knife_bowie',
|
||||
'knife_butterfly',
|
||||
'knife_canis',
|
||||
'knife_cord',
|
||||
'knife_css',
|
||||
'knife_falchion',
|
||||
'knife_flip',
|
||||
'knife_gut',
|
||||
'knife_gypsy_jackknife',
|
||||
'knife_karambit',
|
||||
'knife_kukri',
|
||||
'knife_m9_bayonet',
|
||||
'knife_push',
|
||||
'knife_skeleton',
|
||||
'knife_stiletto',
|
||||
'knife_survival_bowie',
|
||||
'knife_t',
|
||||
'knife_tactical',
|
||||
'knife_twinblade',
|
||||
'knife_ursus',
|
||||
'knife_widowmaker',
|
||||
'knifegg',
|
||||
'm4a1',
|
||||
'm4a1_silencer',
|
||||
'm4a1_silencer_off',
|
||||
'm249',
|
||||
'mac10',
|
||||
'mag7',
|
||||
'melee',
|
||||
'molotov',
|
||||
'movelinear',
|
||||
'mp5sd',
|
||||
'mp7',
|
||||
'mp9',
|
||||
'negev',
|
||||
'nova',
|
||||
'p90',
|
||||
'p250',
|
||||
'p2000',
|
||||
'planted_c4_survival',
|
||||
'planted_c4',
|
||||
'prop_exploding_barrel',
|
||||
'radarjammer',
|
||||
'revolver',
|
||||
'sawedoff',
|
||||
'scar20',
|
||||
'sg556',
|
||||
'shield',
|
||||
'smokegrenade',
|
||||
'snowball',
|
||||
'spanner',
|
||||
'spray0',
|
||||
'ssg08',
|
||||
'stomp_damage',
|
||||
'tablet',
|
||||
'tagrenade',
|
||||
'taser',
|
||||
'tec9',
|
||||
'tripwirefire_projectile',
|
||||
'tripwirefire',
|
||||
'ump45',
|
||||
'usp_silencer',
|
||||
'usp_silencer_off',
|
||||
'xm1014',
|
||||
'zone_repulsor',
|
||||
]
|
||||
|
||||
export const CStrikeWeaponMap: Record<string, string> = {
|
||||
'228-compact': '228-compact',
|
||||
'550-commando': '550-commando',
|
||||
ak47: 'ak47',
|
||||
aug: 'aug',
|
||||
awp: 'awp',
|
||||
'cs-grenade': 'CS手雷',
|
||||
'd3_au-1': 'D3/AU-1',
|
||||
deagle: 'deagle',
|
||||
'dual-elites': 'dual-elites',
|
||||
'famas-1': 'famas-1',
|
||||
famas: 'famas',
|
||||
flare: 'flare',
|
||||
'gauge-super': 'gauge-super',
|
||||
glock: 'glock',
|
||||
'he-grenade': 'HE手雷',
|
||||
'idf-defender': 'idf-defender',
|
||||
knife: 'knife',
|
||||
'krieg-552': 'krieg-552',
|
||||
'law-grenade': 'Law手雷',
|
||||
'm249-2': 'm249-2',
|
||||
m249: 'm249',
|
||||
'mac10-1': 'mac10-1',
|
||||
mac10: 'mac10',
|
||||
melee: 'melee',
|
||||
mp5: 'mp5',
|
||||
nade: 'nade',
|
||||
p90: 'p90',
|
||||
rifle: 'rifle',
|
||||
scout: 'scout',
|
||||
sg552: 'sg552',
|
||||
suicide: '自杀',
|
||||
train: 'train',
|
||||
ump45: 'ump45',
|
||||
'usp-silencer': 'usp-silencer',
|
||||
vault: 'vault',
|
||||
wtf: 'wtf',
|
||||
yg1265: 'yg1265',
|
||||
}
|
||||
export const CS2WeaponMap: Record<string, string> = {
|
||||
ak47: 'AK47',
|
||||
ammobox: '弹药箱',
|
||||
ammobox_threepack: '弹药箱三件套',
|
||||
armor: '护甲',
|
||||
armor_helmet: '护甲头盔',
|
||||
assaultsuit_helmet_only: '突击套装头盔',
|
||||
assaultsuit: '突击套装',
|
||||
aug: 'AUG',
|
||||
awp: 'AWP',
|
||||
axe: '斧头',
|
||||
bayonet: '刺刀',
|
||||
bizon: 'PP野牛',
|
||||
breachcharge: '遥控炸弹',
|
||||
breachcharge_projectile: '遥控炸弹投射物',
|
||||
bumpmine: '弹射地雷',
|
||||
c4: 'C4炸弹',
|
||||
clothing_hands: '服装手',
|
||||
controldrone: '无人机',
|
||||
customplayer: '自定义玩家',
|
||||
cz75a: 'CZ75',
|
||||
deagle: '沙漠之鹰',
|
||||
decoy: '诱饵弹',
|
||||
defuser: '拆弹器',
|
||||
disconnect: '断开连接',
|
||||
diversion: '分散注意',
|
||||
dronegun: '无人机枪',
|
||||
elite: '精英',
|
||||
famas: '法玛斯',
|
||||
firebomb: '燃烧弹',
|
||||
fists: '拳头',
|
||||
fiveseven: 'FN57',
|
||||
flair0: '天赋',
|
||||
flashbang: '闪光弹',
|
||||
flashbang_assist: '闪光弹助攻',
|
||||
frag_grenade: '破片手榴弹',
|
||||
g3sg1: 'G3SG1',
|
||||
galilar: '加利尔',
|
||||
glock: '格洛克',
|
||||
grenadepack: '手榴弹包',
|
||||
grenadepack2: '手榴弹包2',
|
||||
hammer: '锤子',
|
||||
healthshot: '医疗针',
|
||||
heavy_armor: '重装甲',
|
||||
hegrenade: '高爆手榴弹',
|
||||
helmet: '头盔',
|
||||
hkp2000: 'P2000',
|
||||
incgrenade: '燃烧手榴弹',
|
||||
inferno: '地狱火',
|
||||
kevlar: '凯夫拉',
|
||||
knife: '刀',
|
||||
knife_bowie: '鲍伊猎刀',
|
||||
knife_butterfly: '蝴蝶刀',
|
||||
knife_canis: '求生匕首',
|
||||
knife_cord: '系绳匕首',
|
||||
knife_css: '海豹短刀',
|
||||
knife_falchion: '弯刀',
|
||||
knife_flip: '折叠刀',
|
||||
knife_gut: '穿肠刀',
|
||||
knife_gypsy_jackknife: '折刀',
|
||||
knife_karambit: '爪子刀',
|
||||
knife_kukri: '廓尔喀弯刀',
|
||||
knife_m9_bayonet: 'M9刺刀',
|
||||
knife_push: '暗影双匕',
|
||||
knife_skeleton: '骷髅匕首',
|
||||
knife_stiletto: '短剑',
|
||||
knife_survival_bowie: '求生鲍伊猎刀',
|
||||
knife_t: 'T刀',
|
||||
knife_tactical: '猎杀者匕首',
|
||||
knife_twinblade: '双刃匕首',
|
||||
knife_ursus: '熊刀',
|
||||
knife_widowmaker: '锯齿爪刀',
|
||||
knifegg: 'gg刀',
|
||||
m4a1: 'M4A4',
|
||||
m4a1_silencer: 'M4A1消音版',
|
||||
m4a1_silencer_off: 'M4A1无消音器',
|
||||
m249: 'M249',
|
||||
mac10: 'MAC-10',
|
||||
mag7: 'MAG-7',
|
||||
melee: '幽灵之刃',
|
||||
molotov: '燃烧弹',
|
||||
movelinear: '拳击',
|
||||
mp5sd: 'MP5',
|
||||
mp7: 'MP7',
|
||||
mp9: 'MP9',
|
||||
negev: '内格夫',
|
||||
nova: '新星',
|
||||
p90: 'P90',
|
||||
p250: 'P250',
|
||||
p2000: 'P2000',
|
||||
planted_c4_survival: '放置C4生存',
|
||||
planted_c4: '放置C4',
|
||||
prop_exploding_barrel: '爆炸桶',
|
||||
radarjammer: '雷达干扰器',
|
||||
revolver: '左轮手枪',
|
||||
sawedoff: '匪喷',
|
||||
scar20: 'SCAR',
|
||||
sg556: 'SG553',
|
||||
shield: '盾牌',
|
||||
smokegrenade: '烟雾弹',
|
||||
snowball: '雪球',
|
||||
spanner: '扳手',
|
||||
spray0: '喷漆',
|
||||
ssg08: 'SSG08',
|
||||
stomp_damage: '踩踏伤害',
|
||||
tablet: '平板',
|
||||
tagrenade: '标记手榴弹',
|
||||
taser: '电击枪',
|
||||
tec9: 'Tec-9',
|
||||
tripwirefire_projectile: '绊网火投射物',
|
||||
tripwirefire: '绊网火',
|
||||
ump45: 'UMP45',
|
||||
usp_silencer: 'USP消音版',
|
||||
usp_silencer_off: 'USP无消音器',
|
||||
xm1014: 'XM1014连喷',
|
||||
zone_repulsor: '区域排斥装置',
|
||||
}
|
||||
530
web/src/app/hud/deathmsg/index.tsx
Normal file
530
web/src/app/hud/deathmsg/index.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
|
||||
import { H2, H3 } from '@/components/Heading'
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||
import {
|
||||
DeathMsg,
|
||||
DefaultDeathMsg,
|
||||
CS2PrefixIconValues,
|
||||
CS2SuffixIconValues,
|
||||
CS2Weapon,
|
||||
CS2WeaponMap,
|
||||
CS2WeaponValues,
|
||||
CStrikeWeaponMap,
|
||||
CStrikeWeaponValues,
|
||||
CStrikePrefixIconValues,
|
||||
CStrikeSuffixIconValues,
|
||||
Camp,
|
||||
CampValues,
|
||||
} from './dmsg'
|
||||
import useDMStore from './store'
|
||||
import { Save, RefreshCcw, Loader, RotateCcw, Plus, Sparkles, Trash2, Square } from 'lucide-react'
|
||||
import { Key, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Input,
|
||||
NumberField,
|
||||
Switch,
|
||||
Tabs,
|
||||
cn,
|
||||
Autocomplete,
|
||||
useFilter,
|
||||
ListBox,
|
||||
SearchField,
|
||||
EmptyState,
|
||||
} from '@heroui/react'
|
||||
import { IoSwapHorizontal } from 'react-icons/io5'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full max-w-screen-lg gap-8 px-8 py-6 mx-auto">
|
||||
<H2>击杀信息生成</H2>
|
||||
|
||||
<GameTypeTabs />
|
||||
{/* <p className="text-zinc-900 dark:text-zinc-100">施工中... 功能尚未完善</p> */}
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-6 pt-4">
|
||||
<SettingPanel />
|
||||
<PreviewPanel />
|
||||
<DeathNoticePanel />
|
||||
</div>
|
||||
<DeathNoticeCanvas />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GameTypeTabs() {
|
||||
const { gameType, setGameType } = useDMStore()
|
||||
return (
|
||||
<Tabs selectedKey={gameType} onSelectionChange={(key: Key) => setGameType(key.toString())} aria-label="Game Type Variants" variant="secondary">
|
||||
<Tabs.ListContainer>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab id="cs2">
|
||||
CS2 & GO
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
{/* <Tab key="csgo" title="CSGO" /> */}
|
||||
<Tabs.Tab id="cstrike">
|
||||
<div className="flex items-center space-x-2 px-2">
|
||||
<span>CStrike</span>
|
||||
<Chip size="sm">
|
||||
<Chip.Label>测试中</Chip.Label>
|
||||
</Chip>
|
||||
</div>
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingPanel() {
|
||||
const { w, h, hidpi, prefix, mockLayout, setW, setH, setHidpi, setPrefix, setMockLayout, reset } = useDMStore()
|
||||
|
||||
return (
|
||||
<section className="flex gap-6 w-full md:w-auto p-6 border border-zinc-300 bg-white/[.01] dark:border-zinc-600 rounded-md text-zinc-900 dark:text-zinc-100">
|
||||
<div className="flex flex-col flex-wrap grow gap-3">
|
||||
<div className="flex gap-4">
|
||||
<H3>偏好设置</H3>
|
||||
<Button size="sm" variant="secondary" onPress={() => reset()} className="font-semibold gap-1">
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a className="min-w-24 text-sm">宽</a>
|
||||
<NumberField value={w} onChange={e => setW(e)} aria-label="宽度">
|
||||
<NumberField.Group>
|
||||
<NumberField.Input />
|
||||
</NumberField.Group>
|
||||
</NumberField>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a className="min-w-24 text-sm">高</a>
|
||||
<NumberField value={h} onChange={h => setH(h)} aria-label="高度">
|
||||
<NumberField.Group>
|
||||
<NumberField.Input />
|
||||
</NumberField.Group>
|
||||
</NumberField>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a className="min-w-24 text-sm">渲染倍率</a>
|
||||
<NumberField value={hidpi} onChange={hidpi => setHidpi(hidpi)} aria-label="渲染倍率">
|
||||
<NumberField.Group>
|
||||
<NumberField.Input />
|
||||
</NumberField.Group>
|
||||
</NumberField>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a className="min-w-24 text-sm">文件名前缀</a>
|
||||
<Input value={prefix} onChange={e => setPrefix(e.target.value)} aria-label="文件名前缀" />
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a className="min-w-24 text-sm">模拟游戏布局</a>
|
||||
<Switch size="sm" isSelected={mockLayout} onChange={mockLayout => setMockLayout(mockLayout)} aria-label="模拟游戏布局">
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewPanel() {
|
||||
return (
|
||||
<section className="flex flex-col grow gap-6 p-6 border border-zinc-300 dark:border-zinc-600 rounded-md dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100">
|
||||
<H3>预览</H3>
|
||||
<DeathNoticeRender />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DeathNoticePanel() {
|
||||
const { dNotices, setDNotice, cstrikeDNotices, saveDNotices, loadDNotices, resetDNotices, addDNotice, generateDNotice, gameType } = useDMStore()
|
||||
const [parent /* , enableAnimations */] = useAutoAnimate(/* optional config */)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col w-full gap-6 p-6 border border-zinc-300 bg-white/[.01] dark:border-zinc-600 rounded-md text-zinc-900 dark:text-zinc-100">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<H3>击杀信息调整</H3>
|
||||
<Button size="sm" variant="secondary" onPress={() => saveDNotices()} className="font-semibold gap-1">
|
||||
<Save size={14} />
|
||||
保存数据
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => loadDNotices('')} className="font-semibold gap-1">
|
||||
<Loader size={14} />
|
||||
加载数据
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => resetDNotices()} className="font-semibold gap-1">
|
||||
<RefreshCcw size={14} />
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => addDNotice(DefaultDeathMsg)} className="ml-auto font-semibold gap-1">
|
||||
<Plus size={14} />
|
||||
添加
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => generateDNotice()} className="font-semibold gap-1">
|
||||
<Sparkles size={14} />
|
||||
生成击杀
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-6" ref={parent}>
|
||||
{(gameType === 'cs2' ? dNotices : cstrikeDNotices).map((dNotice: DeathMsg, i: number) => (
|
||||
<DeathNoticeItem key={i} index={i} deathNotice={dNotice} setDNotice={setDNotice} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
type DeathNoticeItemProps = {
|
||||
index: number
|
||||
deathNotice: DeathMsg
|
||||
setDNotice: (index: number, dNotice: DeathMsg) => void
|
||||
}
|
||||
function DeathNoticeItem({ index, deathNotice, setDNotice }: DeathNoticeItemProps) {
|
||||
const { removeDNotice, gameType } = useDMStore()
|
||||
const PrefixIconValues = gameType == 'cs2' ? CS2PrefixIconValues : CStrikePrefixIconValues
|
||||
const SuffixIconValues = gameType == 'cs2' ? CS2SuffixIconValues : CStrikeSuffixIconValues
|
||||
|
||||
return (
|
||||
<ul className="grid items-center grid-cols-1 gap-4 p-4 border dark:border-zinc-800 rounded-md md:grid-cols-6 dark:bg-zinc-900/50">
|
||||
<li className="col-span-2 flex flex-col gap-1.5 grow">
|
||||
<p>击杀者</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={deathNotice.attacker}
|
||||
onChange={e => setDNotice(index, { ...deathNotice, attacker: e.target.value })}
|
||||
aria-label="击杀者"
|
||||
className="grow"
|
||||
/>
|
||||
<CampButton value={deathNotice.attackerCamp} onChange={camp => setDNotice(index, { ...deathNotice, attackerCamp: camp })} />
|
||||
</div>
|
||||
</li>
|
||||
<li className="col-span-2 flex flex-col gap-1.5 grow">
|
||||
<p className="w-full flex items-center justify-between">受害者</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={deathNotice.victim}
|
||||
onChange={e => setDNotice(index, { ...deathNotice, victim: e.target.value })}
|
||||
aria-label="受害者"
|
||||
className="grow"
|
||||
/>
|
||||
<CampButton value={deathNotice.victimCamp} onChange={camp => setDNotice(index, { ...deathNotice, victimCamp: camp })} />
|
||||
</div>
|
||||
</li>
|
||||
<li className="col-span-2 flex flex-col gap-1.5 grow">
|
||||
<p>武器</p>
|
||||
<div className="flex gap-3">
|
||||
<SelectSearch
|
||||
value={deathNotice.weapon}
|
||||
values={gameType == 'cs2' ? CS2WeaponValues : CStrikeWeaponValues}
|
||||
valueMap={gameType == 'cs2' ? CS2WeaponMap : CStrikeWeaponMap}
|
||||
onChange={(value: string) => setDNotice(index, { ...deathNotice, weapon: value as CS2Weapon })}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li className="col-span-4 flex flex-col gap-1.5">
|
||||
<p>图标</p>
|
||||
<ul className="flex gap-2 rounded-lg">
|
||||
{PrefixIconValues.map(item => {
|
||||
const isSelected = deathNotice.prefixIcons.includes(item)
|
||||
return (
|
||||
<li key={item}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
isSelected
|
||||
? setDNotice(index, { ...deathNotice, prefixIcons: deathNotice.prefixIcons.filter(i => i !== item) })
|
||||
: setDNotice(index, { ...deathNotice, prefixIcons: [...deathNotice.prefixIcons, item] })
|
||||
}
|
||||
aria-label={`切换前缀图标 ${item}`}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'w-8 h-8 p-1.5 rounded-lg cursor-pointer transition active:scale-95 border',
|
||||
isSelected
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700'
|
||||
: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-800/50 hover:bg-blue-100 dark:hover:bg-blue-900/40'
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`/cs2/deathnotice/${item}.svg`}
|
||||
alt={`前缀图标 ${item}`}
|
||||
className="w-full h-full transition-all"
|
||||
style={
|
||||
isSelected
|
||||
? {
|
||||
filter: 'brightness(0) saturate(100%) invert(45%) sepia(60%) saturate(800%) hue-rotate(180deg) brightness(95%) grayscale(20%)',
|
||||
}
|
||||
: {
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(45%) sepia(60%) saturate(800%) hue-rotate(180deg) brightness(95%) grayscale(20%) opacity(0.2)',
|
||||
}
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{SuffixIconValues.map(item => {
|
||||
const isSelected = deathNotice.suffixIcons.includes(item)
|
||||
return (
|
||||
<li key={item}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
isSelected
|
||||
? setDNotice(index, { ...deathNotice, suffixIcons: deathNotice.suffixIcons.filter(i => i !== item) })
|
||||
: setDNotice(index, { ...deathNotice, suffixIcons: [...deathNotice.suffixIcons, item] })
|
||||
}
|
||||
aria-label={`切换后缀图标 ${item}`}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'w-8 h-8 p-1.5 rounded-lg cursor-pointer transition active:scale-95 border',
|
||||
isSelected
|
||||
? 'bg-orange-100 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700'
|
||||
: 'bg-orange-50 dark:bg-orange-950/30 border-orange-100 dark:border-orange-800/50 hover:bg-orange-100 dark:hover:bg-orange-900/40'
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`/cs2/deathnotice/${item}.svg`}
|
||||
alt={`后缀图标 ${item}`}
|
||||
className="w-full h-full transition-all"
|
||||
style={
|
||||
isSelected
|
||||
? {
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(55%) sepia(50%) saturate(700%) hue-rotate(350deg) brightness(90%) contrast(98%) grayscale(25%)',
|
||||
}
|
||||
: {
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(55%) sepia(50%) saturate(700%) hue-rotate(350deg) brightness(90%) contrast(98%) grayscale(25%) opacity(0.25)',
|
||||
}
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
<li className="col-span-2 mt-auto ml-auto space-x-2 flex">
|
||||
<Button variant="secondary" onPress={() => removeDNotice(index)} size="sm" className="font-semibold hover:bg-red-300 hover:text-white gap-1">
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className=" gap-1 font-semibold"
|
||||
onPress={() => setDNotice(index, { ...deathNotice, attacker: deathNotice.victim, victim: deathNotice.attacker })}
|
||||
>
|
||||
<IoSwapHorizontal size={14} />
|
||||
交换
|
||||
</Button>
|
||||
{gameType == 'cs2' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => setDNotice(index, { ...deathNotice, redBorder: !deathNotice.redBorder })}
|
||||
className={cn('font-semibold gap-1', deathNotice.redBorder && 'border-red-500 border text-red-400 bg-red-100 dark:bg-red-900/30')}
|
||||
>
|
||||
<Square size={14} />
|
||||
红框
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
type CampButtonProps = {
|
||||
value: Camp
|
||||
onChange: (value: Camp) => void
|
||||
label?: string
|
||||
}
|
||||
|
||||
function CampButton({ value, onChange, label }: CampButtonProps) {
|
||||
const { gameType } = useDMStore()
|
||||
|
||||
const getNextCamp = (current: Camp): Camp => {
|
||||
return current === 'CT' ? 'T' : 'CT'
|
||||
}
|
||||
|
||||
const getCampColor = (camp: Camp) => {
|
||||
if (camp === '') return 'border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400'
|
||||
if (gameType === 'cs2') {
|
||||
return camp === 'CT' ? 'border-[#6F9CE6] text-[#6F9CE6]' : 'border-[#EABE54] text-[#EABE54]'
|
||||
} else {
|
||||
return camp === 'CT' ? 'border-[#a8d5fe] text-[#a8d5fe]' : 'border-[#f84444] text-[#f84444]'
|
||||
}
|
||||
}
|
||||
|
||||
const displayValue = value || '—'
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onPress={() => onChange(getNextCamp(value))}
|
||||
className={cn('font-semibold min-w-12 border-[1.5px]', getCampColor(value))}
|
||||
aria-label={`切换阵营,当前:${value === 'CT' ? '反恐精英' : value === 'T' ? '恐怖分子' : '无'}`}
|
||||
>
|
||||
{displayValue}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectSearchProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
values: string[]
|
||||
valueMap: Record<string, string>
|
||||
}
|
||||
|
||||
function SelectSearch({ value, onChange, values, valueMap }: SelectSearchProps) {
|
||||
const { contains } = useFilter({ sensitivity: 'base' })
|
||||
const { gameType } = useDMStore()
|
||||
|
||||
// Prepare items for Autocomplete
|
||||
const items = values.map(v => ({ id: v, name: valueMap[v] || v }))
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
className="w-full"
|
||||
placeholder="选择武器"
|
||||
selectedKey={value}
|
||||
onSelectionChange={key => onChange((key as string) || '')}
|
||||
aria-label="选择武器"
|
||||
>
|
||||
<Autocomplete.Trigger>
|
||||
<Autocomplete.Value>
|
||||
{({ selectedItem, isPlaceholder }) => {
|
||||
const v = value
|
||||
if (isPlaceholder || !v) return <span className="text-gray-500">选择武器</span>
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={gameType === 'cs2' ? `/cs2/weapon/${v}.svg` : `/cstrike/weapon/${v}.png`}
|
||||
alt={v ? `武器 ${valueMap[v] || v}` : '未选择武器'}
|
||||
className={cn(gameType === 'cs2' ? 'h-6 p-0.5 invert-[0.7] dark:invert-[0.2]' : 'h-6 p-0', 'rounded')}
|
||||
/>
|
||||
<span>{valueMap[v] || v}</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Autocomplete.Value>
|
||||
<Autocomplete.Indicator />
|
||||
</Autocomplete.Trigger>
|
||||
<Autocomplete.Popover>
|
||||
<Autocomplete.Filter filter={contains}>
|
||||
<SearchField autoFocus aria-label="Search weapons">
|
||||
<SearchField.Group>
|
||||
<SearchField.Input placeholder="搜索武器装备..." />
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
<ListBox renderEmptyState={() => <EmptyState>没有找到武器装备</EmptyState>}>
|
||||
{items.map(item => (
|
||||
<ListBox.Item key={item.id} id={item.id} textValue={item.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={gameType === 'cs2' ? `/cs2/weapon/${item.id}.svg` : `/cstrike/weapon/${item.id}.png`}
|
||||
alt="weapon"
|
||||
className={cn(gameType === 'cs2' ? 'h-6 max-w-10 p-0.5 invert-[0.7] dark:invert-[0.2]' : 'h-6 w-16 p-0', 'bg-contain rounded ')}
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Autocomplete.Filter>
|
||||
</Autocomplete.Popover>
|
||||
</Autocomplete>
|
||||
)
|
||||
}
|
||||
|
||||
type DeathNoticeRenderProps = {
|
||||
hide?: boolean
|
||||
}
|
||||
|
||||
function DeathNoticeRender({ hide = false }: DeathNoticeRenderProps) {
|
||||
const { dNotices, cstrikeDNotices, gameType } = useDMStore()
|
||||
const [parent /* , enableAnimations */] = useAutoAnimate(/* optional config */)
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col items-end gap-1 pr-2.5 transition-transform" ref={parent}>
|
||||
{(gameType === 'cs2' ? dNotices : cstrikeDNotices).map((dNotice: DeathMsg, index: number) =>
|
||||
gameType === 'cs2' ? (
|
||||
<CS2DeathNoticeItem key={index} dNotice={dNotice} index={index} hide={hide} />
|
||||
) : (
|
||||
<CStrikeDeathNoticeItem key={index} dNotice={dNotice} index={index} hide={hide} />
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function CS2DeathNoticeItem({ dNotice, index, hide }: { dNotice: DeathMsg; index: number; hide: boolean }) {
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-center gap-1 px-2 py-2 h-8 text-sm leading-6 backdrop-blur font-bold font-[Stratum2] rounded text-white bg-black/65',
|
||||
dNotice.redBorder && 'border-2 border-[#e10000]',
|
||||
hide && dNotice.hide && 'invisible'
|
||||
)}
|
||||
>
|
||||
<p className={cn(dNotice.attackerCamp === 'CT' ? 'text-[#6F9CE6]' : 'text-[#EABE54]')}>{dNotice.attacker}</p>
|
||||
{dNotice.prefixIcons &&
|
||||
dNotice.prefixIcons.map((prefixIcon: string, i: number) => <img src={`/cs2/deathnotice/${prefixIcon}.svg`} alt="prefix" className="h-6" key={i} />)}
|
||||
<img src={`/cs2/weapon/${dNotice.weapon}.svg`} alt="weapon" className="h-6" />
|
||||
{dNotice.suffixIcons &&
|
||||
dNotice.suffixIcons.map((suffixIcon: string, i: number) => <img src={`/cs2/deathnotice/${suffixIcon}.svg`} alt="suffix" className="h-6" key={i} />)}
|
||||
|
||||
<p className={cn(dNotice.victimCamp === 'CT' ? 'text-[#6F9CE6]' : 'text-[#EABE54]')}>{dNotice.victim}</p>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function CStrikeDeathNoticeItem({ dNotice, index, hide }: { dNotice: DeathMsg; index: number; hide: boolean }) {
|
||||
// TODO 图片后处理
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-center gap-1 px-2 py-1 h-8 text-sm leading-6 backdrop-blur font-light font-[Verdana] rounded text-white',
|
||||
hide && dNotice.hide && 'invisible'
|
||||
)}
|
||||
>
|
||||
{dNotice.prefixIcons &&
|
||||
dNotice.prefixIcons.map((prefixIcon: string, i: number) => (
|
||||
<img src={`/cstrike/deathnotice/${prefixIcon}.png`} alt="prefix" className="h-6 sepia" key={i} />
|
||||
))}
|
||||
<p className={cn('drop-shadow-[1px_0.5px_0_rgba(0,0,0,1)]', dNotice.attackerCamp === 'CT' ? 'text-[#a8d5fe]' : 'text-[#f84444]')}>{dNotice.attacker}</p>
|
||||
<img src={`/cstrike/weapon/${dNotice.weapon}.png`} alt="weapon" className="h-6 py-0.5 sepia" />
|
||||
{dNotice.suffixIcons &&
|
||||
dNotice.suffixIcons.map((suffixIcon: string, i: number) => (
|
||||
<img src={`/cstrike/deathnotice/${suffixIcon}.png`} alt="suffix" className="h-6 py-0.5 sepia" key={i} />
|
||||
))}
|
||||
|
||||
<p className={cn('drop-shadow-[1px_0.5px_0_rgba(0,0,0,1)]', dNotice.victimCamp === 'CT' ? 'text-[#a8d5fe]' : 'text-[#f84444]')}>{dNotice.victim}</p>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function DeathNoticeCanvas() {
|
||||
const { w, h } = useDMStore()
|
||||
const dw = `${w}px`
|
||||
const dh = `${h}px`
|
||||
|
||||
return (
|
||||
<section className={`pt-[72px] pr-[10px] pointer-events-none absolute bottom-full render-fix`} style={{ width: dw, height: dh }} id="deathnotice">
|
||||
<DeathNoticeRender hide={true} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
179
web/src/app/hud/deathmsg/store.ts
Normal file
179
web/src/app/hud/deathmsg/store.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { create } from 'zustand'
|
||||
import { CStrikeDefaultDeathMsgs, DeathMsg, DefaultDeathMsgs } from './dmsg'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { Canvas2Image } from './canvas'
|
||||
import { toast } from '@heroui/react'
|
||||
|
||||
interface DMState {
|
||||
w: number
|
||||
h: number
|
||||
hidpi: number
|
||||
prefix: string
|
||||
gameType: string
|
||||
dNotices: DeathMsg[]
|
||||
cstrikeDNotices: DeathMsg[]
|
||||
mockLayout: boolean
|
||||
setW: (w: number) => void
|
||||
setH: (h: number) => void
|
||||
setHidpi: (hidpi: number) => void
|
||||
setPrefix: (prefix: string) => void
|
||||
setGameType: (gameType: string) => void
|
||||
reset: () => void
|
||||
setDNotices: (dNotices: DeathMsg[]) => void
|
||||
setDNotice: (index: number, dNotice: DeathMsg) => void
|
||||
addDNotice: (dNotice: DeathMsg) => void
|
||||
removeDNotice: (index: number) => void
|
||||
resetDNotices: () => void
|
||||
saveDNotices: () => void
|
||||
loadDNotices: (json: string) => void
|
||||
generateDNotice: () => void
|
||||
setMockLayout: (mockLayout: boolean) => void
|
||||
}
|
||||
|
||||
// 创建 store
|
||||
const useDMStore = create<DMState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
hidpi: 2,
|
||||
prefix: '击杀生成',
|
||||
gameType: 'cs2',
|
||||
dNotices: DefaultDeathMsgs,
|
||||
cstrikeDNotices: CStrikeDefaultDeathMsgs,
|
||||
mockLayout: true,
|
||||
setW: (w: number) => set({ w }),
|
||||
setH: (h: number) => set({ h }),
|
||||
setHidpi: (hidpi: number) => set({ hidpi }),
|
||||
setPrefix: (prefix: string) => set({ prefix }),
|
||||
setGameType: (gameType: string) => set({ gameType }),
|
||||
reset: () => set({ w: 1920, h: 1080, hidpi: 2, prefix: '击杀生成', dNotices: DefaultDeathMsgs, mockLayout: true }),
|
||||
setDNotices: (dNotices: DeathMsg[]) => {
|
||||
get().gameType === 'cs2' ? set({ dNotices: dNotices }) : set({ cstrikeDNotices: dNotices })
|
||||
},
|
||||
setDNotice: (index: number, dNotice: DeathMsg) => {
|
||||
get().gameType === 'cs2'
|
||||
? set((state: DMState) => ({ dNotices: [...state.dNotices.slice(0, index), dNotice, ...state.dNotices.slice(index + 1)] }))
|
||||
: set((state: DMState) => ({ cstrikeDNotices: [...state.cstrikeDNotices.slice(0, index), dNotice, ...state.cstrikeDNotices.slice(index + 1)] }))
|
||||
},
|
||||
addDNotice: (dNotice: DeathMsg) => {
|
||||
get().gameType === 'cs2'
|
||||
? set((state: DMState) => ({ dNotices: [...state.dNotices, dNotice] }))
|
||||
: set((state: DMState) => ({ cstrikeDNotices: [...state.cstrikeDNotices, dNotice] }))
|
||||
},
|
||||
removeDNotice: (index: number) => {
|
||||
get().gameType === 'cs2'
|
||||
? set((state: DMState) => ({ dNotices: state.dNotices.filter((_, i: number) => i !== index) }))
|
||||
: set((state: DMState) => ({ cstrikeDNotices: state.cstrikeDNotices.filter((_, i: number) => i !== index) }))
|
||||
},
|
||||
resetDNotices: () => {
|
||||
get().gameType === 'cs2' ? set({ dNotices: DefaultDeathMsgs }) : set({ cstrikeDNotices: CStrikeDefaultDeathMsgs })
|
||||
},
|
||||
saveDNotices: () => {
|
||||
const jsonData = JSON.stringify({ dNotices: get().gameType === 'cs2' ? get().dNotices : get().cstrikeDNotices })
|
||||
// 弹出下载
|
||||
const blob = new Blob([jsonData], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = get().prefix + '_dNotices.json'
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
// 等待一小段时间确保下载已触发,然后再移除元素
|
||||
setTimeout(() => {
|
||||
if (a.parentNode) {
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
}, 100)
|
||||
},
|
||||
loadDNotices: (json: string) => {
|
||||
// 上传文件并读取json
|
||||
// 弹出文件上传对话框
|
||||
// set({ dNotices: JSON.parse(json).dNotices })
|
||||
},
|
||||
generateDNotice: async () => {
|
||||
const { w, h, hidpi, prefix, dNotices, cstrikeDNotices, gameType } = get()
|
||||
const currentDNotices = gameType === 'cs2' ? dNotices : cstrikeDNotices
|
||||
|
||||
// html2canvas获取元素、生成图片、并跳转下载
|
||||
const e = document.getElementById('deathnotice')
|
||||
if (e === null) return
|
||||
|
||||
// 生产环境适配
|
||||
const style = document.createElement('style')
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
document.head.appendChild(style)
|
||||
style.sheet?.insertRule('body > div:last-child img { display: inline-block; }')
|
||||
}
|
||||
|
||||
const dpi = hidpi ? hidpi : 1 // 缩放倍率,不随浏览器缩放改变
|
||||
|
||||
const mockLayoutGeneration = async () => {
|
||||
// 生成过程 模拟游戏布局
|
||||
for (let i = 0; i < currentDNotices.length; i++) {
|
||||
// 隐藏其他击杀条
|
||||
for (let j = 0; j < currentDNotices.length; j++) {
|
||||
const dNotice = currentDNotices[j]
|
||||
get().setDNotice(j, { ...dNotice, hide: j !== i })
|
||||
}
|
||||
|
||||
await sleep(50)
|
||||
|
||||
await Canvas2Image(e, prefix + '-' + (i + 1), dpi)
|
||||
}
|
||||
|
||||
for (let j = 0; j < currentDNotices.length; j++) {
|
||||
const dNotice = currentDNotices[j]
|
||||
get().setDNotice(j, { ...dNotice, hide: false })
|
||||
}
|
||||
}
|
||||
|
||||
const simpleGeneration = async () => {
|
||||
// 分别渲染 DeathNoticeRender 的所有 li组件,并下载png
|
||||
for (let i = 0; i < currentDNotices.length; i++) {
|
||||
get().setDNotice(i, { ...currentDNotices[i], hide: false })
|
||||
}
|
||||
|
||||
const dnList = e.getElementsByTagName('li')
|
||||
let i = 1
|
||||
for (const dn of dnList) {
|
||||
await sleep(10)
|
||||
await Canvas2Image(dn, prefix + '-' + i, dpi)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (get().mockLayout) {
|
||||
await mockLayoutGeneration()
|
||||
} else {
|
||||
await simpleGeneration()
|
||||
}
|
||||
} finally {
|
||||
// 确保 style 元素被移除
|
||||
if (style.parentNode) {
|
||||
style.remove()
|
||||
}
|
||||
}
|
||||
toast('生成成功', {
|
||||
description: '请查看下载文件夹',
|
||||
})
|
||||
},
|
||||
setMockLayout: (mockLayout: boolean) => set({ mockLayout }),
|
||||
}),
|
||||
{
|
||||
name: 'deathmsg',
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
function sleep(time: number) {
|
||||
return new Promise(function (resolve) {
|
||||
setTimeout(resolve, time)
|
||||
})
|
||||
}
|
||||
|
||||
export default useDMStore
|
||||
7
web/src/app/hud/layout.tsx
Normal file
7
web/src/app/hud/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function HudLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return <main className="flex flex-col items-center justify-start min-h-screen gap-20 py-24 mx-auto">{children}</main>
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CreateResourceModal } from '../components/CreateResourceModal'
|
||||
import { EditResourceModal } from '../components/EditResourceModal'
|
||||
import { useSession } from '../lib/auth-client'
|
||||
import { Card } from '../components/Card'
|
||||
import { CardSkeleton } from '../components/CardSkeleton'
|
||||
import { ResourceCardList } from '../components/ResourceCardList'
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -25,7 +26,11 @@ export default function HomePage() {
|
||||
<section className="mb-20">
|
||||
<h2 className="mb-12 text-center text-3xl font-bold">传送门</h2>
|
||||
{isPortalsLoading ? (
|
||||
<div className="py-20 text-center">加载中...</div>
|
||||
<ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{portals?.map((portal) => (
|
||||
@@ -49,7 +54,7 @@ export default function HomePage() {
|
||||
{session && (
|
||||
<Button
|
||||
onPress={() => setIsModalOpen(true)}
|
||||
variant="flat"
|
||||
variant="ghost"
|
||||
className="absolute right-0"
|
||||
>
|
||||
添加资源
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FieldError,
|
||||
} from '@heroui/react'
|
||||
import { buttonVariants } from '@heroui/styles'
|
||||
import { signIn } from '../../lib/auth-client'
|
||||
import { signIn } from '@/lib/auth-client'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
@@ -11,11 +11,28 @@ export default function PostDetailPage() {
|
||||
return (
|
||||
<div className="pb-20">
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Card className="mx-auto max-w-4xl space-y-4 p-6">
|
||||
<Skeleton className="h-8 w-3/4 rounded-lg" />
|
||||
<Skeleton className="h-4 w-1/4 rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
</Card>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-4">
|
||||
<Skeleton className="h-6 w-32 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
<Card className="mb-8 p-6">
|
||||
<Card.Header className="flex flex-col items-start gap-4">
|
||||
<Skeleton className="h-10 w-3/4 rounded-lg dark:bg-zinc-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-6 rounded-full dark:bg-zinc-700" />
|
||||
<Skeleton className="h-4 w-48 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Separator className="my-4" />
|
||||
<Card.Content className="space-y-3">
|
||||
<Skeleton className="h-4 w-full rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-4 w-full rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-4 w-full rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-4 w-2/3 rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="mt-4 h-40 w-full rounded-lg dark:bg-zinc-700" />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { usePosts, Post } from '../../hooks/useApi'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { PostSkeleton } from '../../components/PostSkeleton'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
Label,
|
||||
FieldError,
|
||||
} from '@heroui/react'
|
||||
import { useSession } from '../../lib/auth-client'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export default function PostsPage() {
|
||||
@@ -51,7 +52,11 @@ export default function PostsPage() {
|
||||
<div className="flex-1">
|
||||
<h2 className="mb-6 text-2xl font-bold">最新帖子</h2>
|
||||
{isLoading ? (
|
||||
<div>加载中...</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<PostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{posts?.map((post: Post) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSession, signOut, user } from '../../lib/auth-client'
|
||||
import { useSession, signOut, user } from '@/lib/auth-client'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@heroui/react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ProfileSkeleton } from '../../components/ProfileSkeleton'
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { data: session, isPending } = useSession()
|
||||
@@ -29,11 +30,7 @@ export default function ProfilePage() {
|
||||
}, [session, isPending, navigate])
|
||||
|
||||
if (isPending || !session) {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center">
|
||||
加载中...
|
||||
</div>
|
||||
)
|
||||
return <ProfileSkeleton />
|
||||
}
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -85,7 +82,7 @@ export default function ProfilePage() {
|
||||
<div className="space-y-4">
|
||||
<TextField isReadOnly>
|
||||
<Label>邮箱</Label>
|
||||
<Input value={session.user.email} variant="flat" />
|
||||
<Input value={session.user.email} variant="secondary" />
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
@@ -95,13 +92,13 @@ export default function ProfilePage() {
|
||||
isReadOnly={!isEditing}
|
||||
>
|
||||
<Label>昵称</Label>
|
||||
<Input variant={isEditing ? 'secondary' : 'flat'} />
|
||||
<Input variant={isEditing ? 'primary' : 'secondary'} />
|
||||
<FieldError />
|
||||
</TextField>
|
||||
|
||||
<TextField isReadOnly>
|
||||
<Label>用户ID</Label>
|
||||
<Input value={session.user.id} variant="flat" />
|
||||
<Input value={session.user.id} variant="secondary" />
|
||||
</TextField>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FieldError,
|
||||
} from '@heroui/react'
|
||||
import { buttonVariants } from '@heroui/styles'
|
||||
import { signUp } from '../../lib/auth-client'
|
||||
import { signUp } from '@/lib/auth-client'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
42
web/src/components/AuthSkeleton.tsx
Normal file
42
web/src/components/AuthSkeleton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Skeleton } from "@heroui/react";
|
||||
|
||||
export function AuthSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center p-4">
|
||||
{/* Card Skeleton */}
|
||||
<div className="w-full max-w-md flex flex-col gap-6 rounded-xl bg-zinc-100 dark:bg-zinc-800 p-6 shadow-sm border border-zinc-200 dark:border-zinc-800">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-center mb-2">
|
||||
<Skeleton className="h-8 w-48 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Email */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-12 rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-12 rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* Footer Link */}
|
||||
<div className="flex justify-center mt-2">
|
||||
<Skeleton className="h-4 w-32 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export function Card({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-zinc-500 flex-grow dark:text-[#9e9e9e]">
|
||||
<p className="text-zinc-500 grow dark:text-[#9e9e9e]">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
|
||||
31
web/src/components/CardSkeleton.tsx
Normal file
31
web/src/components/CardSkeleton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from "@heroui/react";
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<li className="flex h-full flex-col gap-3.5 rounded-xl bg-zinc-100 bg-opacity-90 p-5 dark:bg-zinc-800">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Icon Skeleton */}
|
||||
<Skeleton className="mr-2 h-12 w-12 shrink-0 rounded-xl dark:bg-zinc-700" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title Skeleton */}
|
||||
<Skeleton className="h-5 w-32 rounded-lg dark:bg-zinc-700" />
|
||||
{/* Version Skeleton */}
|
||||
<Skeleton className="h-4 w-16 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Skeleton */}
|
||||
<div className="flex flex-col gap-2 grow">
|
||||
<Skeleton className="h-4 w-full rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-4 w-4/5 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* Buttons Skeleton */}
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<Skeleton className="h-8 w-24 rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-8 w-24 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
9
web/src/components/Heading.tsx
Normal file
9
web/src/components/Heading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { cn } from "@heroui/react";
|
||||
|
||||
export function H2({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <h2 className={cn("text-2xl font-bold", className)}>{children}</h2>;
|
||||
}
|
||||
|
||||
export function H3({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <h3 className={cn("text-xl font-bold", className)}>{children}</h3>;
|
||||
}
|
||||
20
web/src/components/LoadingFallback.tsx
Normal file
20
web/src/components/LoadingFallback.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { AuthSkeleton } from "./AuthSkeleton";
|
||||
import { PageSkeleton } from "./PageSkeleton";
|
||||
|
||||
export function LoadingFallback() {
|
||||
const location = useLocation();
|
||||
const path = location.pathname;
|
||||
|
||||
if (path === "/login" || path === "/register") {
|
||||
return <AuthSkeleton />;
|
||||
}
|
||||
|
||||
// For Kill Generation (DeathMsg) or other HUD tools
|
||||
if (path.startsWith("/hud") || path.startsWith("/demo")) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
// Default fallback (Home, About, etc.)
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
27
web/src/components/PageSkeleton.tsx
Normal file
27
web/src/components/PageSkeleton.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from "@heroui/react";
|
||||
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full max-w-screen-lg gap-8 px-8 py-28 mx-auto">
|
||||
{/* Header */}
|
||||
<Skeleton className="h-10 w-48 rounded-lg dark:bg-zinc-700" />
|
||||
|
||||
{/* Tabs / Controls */}
|
||||
<div className="flex gap-4 w-full justify-center">
|
||||
<Skeleton className="h-10 w-32 rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-10 w-32 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-row flex-wrap gap-6 w-full">
|
||||
{/* Panel 1 */}
|
||||
<Skeleton className="flex-grow h-64 rounded-xl dark:bg-zinc-700" />
|
||||
{/* Panel 2 */}
|
||||
<Skeleton className="flex-grow h-64 rounded-xl dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* Bottom Area */}
|
||||
<Skeleton className="w-full h-32 rounded-xl dark:bg-zinc-700" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
web/src/components/PostSkeleton.tsx
Normal file
22
web/src/components/PostSkeleton.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Card, Skeleton } from "@heroui/react";
|
||||
|
||||
export function PostSkeleton() {
|
||||
return (
|
||||
<Card className="p-4" radius="lg">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header area */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-5 w-1/3 rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-3 w-1/4 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-3 w-full rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-3 w-2/3 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
36
web/src/components/ProfileSkeleton.tsx
Normal file
36
web/src/components/ProfileSkeleton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Card, Skeleton } from "@heroui/react";
|
||||
|
||||
export function ProfileSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<Card.Header>
|
||||
<Skeleton className="h-8 w-32 rounded-lg dark:bg-zinc-700" />
|
||||
</Card.Header>
|
||||
<Card.Content className="flex flex-col gap-8 md:flex-row">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Avatar */}
|
||||
<Skeleton className="h-32 w-32 rounded-full dark:bg-zinc-700" />
|
||||
<Skeleton className="h-10 w-24 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Inputs */}
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-12 rounded-lg dark:bg-zinc-700" />
|
||||
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Skeleton className="h-10 w-24 rounded-lg dark:bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import useSWR from 'swr'
|
||||
import { Card, CardProps } from './Card'
|
||||
import { CardSkeleton } from './CardSkeleton'
|
||||
import { useResources, Resource } from '../hooks/useApi'
|
||||
import { fetchResourceReleaseData, LatestRelease } from '../lib/github'
|
||||
import { useSession } from '../lib/auth-client'
|
||||
@@ -38,7 +39,13 @@ export function ResourceCardList({ onEdit }: ResourceCardListProps) {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="py-20 text-center">加载中...</div>
|
||||
return (
|
||||
<ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
if (!resources?.length) {
|
||||
@@ -76,7 +83,7 @@ export function ResourceCardList({ onEdit }: ResourceCardListProps) {
|
||||
session && onEdit ? (
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => onEdit(item)}
|
||||
className="absolute right-2 top-2 z-10 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
|
||||
@@ -37,7 +37,7 @@ export function SiteNavbar() {
|
||||
|
||||
const menuItems = [
|
||||
{ name: '主页', href: '/' },
|
||||
{ name: '击杀生成', href: '/demo' },
|
||||
{ name: '击杀生成', href: '/hud/deathmsg' },
|
||||
{ name: '关于', href: '/about' },
|
||||
]
|
||||
|
||||
@@ -47,10 +47,8 @@ export function SiteNavbar() {
|
||||
<>
|
||||
<nav
|
||||
className={twMerge(
|
||||
'sticky top-0 z-30 w-full bg-white/75 dark:bg-black/70 backdrop-blur-xl transition-all duration-300',
|
||||
shouldShow
|
||||
? 'opacity-100 translate-y-0 border-b border-zinc-100 dark:border-zinc-800 h-auto'
|
||||
: 'opacity-0 h-0 -translate-y-1/4 pointer-events-none'
|
||||
'sticky top-0 z-30 w-full bg-white/75 dark:bg-black/70 backdrop-blur-xl opacity-0 h-0 transition -translate-y-1/4 hover:opacity-100 duration-300',
|
||||
shouldShow && 'opacity-100 translate-y-0 border-b border-zinc-100 dark:border-zinc-800 h-auto'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full max-w-screen-lg gap-4 px-8 py-4 mx-auto">
|
||||
|
||||
BIN
web/src/fonts/GeistMonoVF.woff
Normal file
BIN
web/src/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
web/src/fonts/GeistVF.woff
Normal file
BIN
web/src/fonts/GeistVF.woff
Normal file
Binary file not shown.
BIN
web/src/fonts/Stratum2.ttf
Normal file
BIN
web/src/fonts/Stratum2.ttf
Normal file
Binary file not shown.
BIN
web/src/fonts/Verdana.ttf
Normal file
BIN
web/src/fonts/Verdana.ttf
Normal file
Binary file not shown.
BIN
web/src/fonts/Verdanab.ttf
Normal file
BIN
web/src/fonts/Verdanab.ttf
Normal file
Binary file not shown.
@@ -5,6 +5,8 @@ import { Providers } from './providers'
|
||||
import RootLayout from './layout'
|
||||
import routes from '~react-pages'
|
||||
|
||||
import { LoadingFallback } from './components/LoadingFallback'
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem('theme')
|
||||
@@ -23,7 +25,7 @@ export function App() {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
}, [])
|
||||
|
||||
return <Suspense fallback={<p>Loading...</p>}>{useRoutes(routes)}</Suspense>
|
||||
return <Suspense fallback={<LoadingFallback />}>{useRoutes(routes)}</Suspense>
|
||||
}
|
||||
|
||||
const app = createRoot(document.getElementById('root')!)
|
||||
|
||||
Reference in New Issue
Block a user