feat: Enhance message content handling and improve UI components

- Updated `getShowContentParts` function to handle message content more robustly, ensuring proper display of content parts.
- Refactored `GamepadViewer.vue` to use async component loading for `GamepadDisplay`, added a toggle for real-time preview.
- Implemented debounced search functionality in `PointGoodsView.vue` for improved performance during keyword searches.
- Enhanced `PointOrderView.vue` with order filtering capabilities and added statistics display for better user insights.
- Improved `PointUserHistoryView.vue` by adding export functionality for history data and enhanced filtering options.
- Updated `PointUserLayout.vue` to improve card styling and tab navigation experience.
- Refined `PointUserSettings.vue` layout for better user interaction and added responsive design adjustments.
- Adjusted `vite.config.mts` for better dependency management and build optimization.
This commit is contained in:
Megghy
2025-10-05 15:13:47 +08:00
parent 55e937bf2f
commit 45338ffe7d
26 changed files with 1597 additions and 487 deletions

BIN
bun.lockb

Binary file not shown.

10
default.d.ts vendored
View File

@@ -31,3 +31,13 @@ declare global {
$dialog: DialogProviderInst
}
}
// Vite worker 与样式类型声明
declare module '*?worker' {
const workerConstructor: { new(): Worker }
export default workerConstructor
}
declare module '*.css' {
const content: string
export default content
}

View File

@@ -11,7 +11,6 @@
"knip": "knip"
},
"dependencies": {
"@guolao/vue-monaco-editor": "^1.5.5",
"@hyperdx/browser": "^0.21.2",
"@hyperdx/cli": "^0.1.0",
"@microsoft/signalr": "^9.0.6",
@@ -52,6 +51,7 @@
"jszip": "^3.10.1",
"linqts": "^3.2.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"mitt": "^3.0.1",
"monaco-editor": "^0.53.0",
@@ -65,6 +65,7 @@
"unplugin-vue-markdown": "^29.2.0",
"uuid": "^13.0.0",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-monaco-editor-nls": "^3.0.1",
"vite-svg-loader": "^5.1.0",
"vue": "3.5.22",
"vue-cropperjs": "^5.0.0",
@@ -94,6 +95,7 @@
"stylus": "^0.64.0",
"typescript": "^5.9.2",
"vite-plugin-cdn-import": "^1.0.1",
"vscode-loc": "git+https://github.com/microsoft/vscode-loc.git",
"vue-vine": "^1.7.6"
}
}

View File

@@ -12,16 +12,18 @@ import {
NSpin,
zhCN,
} from 'naive-ui'
import { computed } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import ManageLayout from '@/views/ManageLayout.vue'
import ViewerLayout from '@/views/ViewerLayout.vue'
import { ThemeType } from './api/api-models'
import ClientLayout from './client/ClientLayout.vue'
import TempComponent from './components/TempComponent.vue'
import { isDarkMode, theme } from './Utils'
import OBSLayout from './views/OBSLayout.vue'
import OpenLiveLayout from './views/OpenLiveLayout.vue'
// 将大型布局组件改为异步组件,避免打入入口包
const ManageLayout = defineAsyncComponent(() => import('@/views/ManageLayout.vue'))
const ViewerLayout = defineAsyncComponent(() => import('@/views/ViewerLayout.vue'))
const ClientLayout = defineAsyncComponent(() => import('./client/ClientLayout.vue'))
const OBSLayout = defineAsyncComponent(() => import('./views/OBSLayout.vue'))
const OpenLiveLayout = defineAsyncComponent(() => import('./views/OpenLiveLayout.vue'))
const route = useRoute()
const themeType = useStorage('Settings.Theme', ThemeType.Auto)

6
src/components.d.ts vendored
View File

@@ -19,19 +19,13 @@ declare module 'vue' {
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTime: typeof import('naive-ui')['NTime']

View File

@@ -1,66 +1,121 @@
<script setup lang="ts">
import { editor } from 'monaco-editor' // 全部导入
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
const { language, height = 400 } = defineProps<{
const { language, height = 400, theme = 'vs-dark', options, path } = defineProps<{
language: string
height?: number
theme?: string
options?: Record<string, any>
path?: string
}>()
const value = defineModel<string>('value')
const editorContainer = ref<HTMLElement>()
let editorInstance: editor.IStandaloneCodeEditor | null = null
// 在创建编辑器前确保容器存在
const ready = ref(false)
const initError = ref<string | null>(null)
const containerRef = ref<HTMLElement | null>(null)
let editor: monaco.editor.IStandaloneCodeEditor | null = null
let model: monaco.editor.ITextModel | null = null
let createdModel = false
onMounted(() => {
if (!editorContainer.value) return
editorInstance = editor.create(editorContainer.value, {
value: value.value,
language,
minimap: {
enabled: true,
// 配置 Monaco Environment
;(self as any).MonacoEnvironment = {
getWorker(_: string, label: string) {
if (label === 'json') {
return new jsonWorker()
}
if (label === 'css' || label === 'scss' || label === 'less') {
return new cssWorker()
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new htmlWorker()
}
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
colorDecorators: true,
}
onMounted(async () => {
try {
const uri = monaco.Uri.parse(
path ?? `inmemory://model/${crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}.${language ?? 'txt'}`,
)
model = monaco.editor.getModel(uri)
if (!model) {
model = monaco.editor.createModel(value?.value ?? '', language, uri)
createdModel = true
}
editor = monaco.editor.create(containerRef.value!, {
model,
theme,
automaticLayout: true,
...(options ?? {}),
})
editorInstance.onDidChangeModelContent(() => {
if (editorInstance) {
const currentValue = editorInstance.getValue()
if (currentValue !== value.value) {
value.value = currentValue
}
// 同步 model -> v-model
editor.onDidChangeModelContent(() => {
const current = model!.getValue()
if (current !== value.value) value.value = current
})
ready.value = true
} catch (err) {
console.error('Monaco 初始化失败:', err)
initError.value = (err as Error)?.message ?? String(err)
}
})
// v-model 变更 -> model
watch(value, (nv) => {
if (model && typeof nv === 'string' && nv !== model.getValue()) {
model.setValue(nv)
}
})
// 语言切换
watch(() => language, (lang) => {
if (model && lang) {
monaco.editor.setModelLanguage(model, lang)
}
})
// 主题切换
watch(() => theme, (t) => {
if (editor && t) {
editor.updateOptions({ theme: t })
}
})
onBeforeUnmount(() => {
if (editorInstance) {
editorInstance.dispose()
editorInstance = null
}
})
watch(value, (newValue) => {
if (editorInstance && newValue !== editorInstance.getValue()) {
editorInstance.setValue(newValue ?? '')
}
})
watch(() => language, (newLang) => {
if (editorInstance) {
const model = editorInstance.getModel()
if (model) {
editor.setModelLanguage(model, newLang)
}
try {
editor?.dispose()
// 仅销毁我们创建的临时 model避免复用路径时把共享 model 误删
if (createdModel && model) {
model.dispose()
}
} catch {}
})
</script>
<template>
<div
ref="editorContainer"
:style="`height: ${height}px;`"
/>
<div :style="`height: ${height}px; width: 100%; position: relative;`">
<div v-if="!ready" :style="`position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color: var(--text-color, #888); text-align:center; padding:8px;`">
<div>
<div>正在加载编辑器</div>
<div v-if="initError" style="margin-top:6px; color:#d9534f; font-size:12px;">{{ initError }}</div>
</div>
</div>
<div ref="containerRef" :style="`height: ${height}px; width: 100%;`"></div>
</div>
</template>

View File

@@ -20,24 +20,34 @@ const themeVars = useThemeVars()
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
// 常量定义
const MILLISECONDS_PER_DAY = 86400000
function getISOWeek(date: Date) {
const target = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const dayNumber = target.getUTCDay() || 7
target.setUTCDate(target.getUTCDate() + 4 - dayNumber)
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
const week = Math.ceil(((target.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
const week = Math.ceil(((target.getTime() - yearStart.getTime()) / MILLISECONDS_PER_DAY + 1) / 7)
return {
year: target.getUTCFullYear(),
week,
}
}
const currentISOWeek = getISOWeek(new Date())
const now = new Date()
const currentISOWeek = getISOWeek(now)
const currentDayOfWeek = (now.getDay() + 6) % 7 // 转换为周一=0的格式
function isCurrentWeek(year: number, week: number) {
return year === currentISOWeek.year && week === currentISOWeek.week
}
function isCurrentDay(year: number, week: number, dayIndex: number) {
if (!isCurrentWeek(year, week)) return false
return dayIndex === currentDayOfWeek
}
const dateFormatter = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
@@ -51,17 +61,40 @@ function getWeekRangeLabel(year: number, week: number) {
function getDateFromWeek(year: number, week: number, dayOfWeek: number): Date {
// week starts from 1-52, dayOfWeek starts from 0-6 where 0 is Monday
const simple = new Date(year, 0, 1 + (week - 1) * 7)
const dow = simple.getDay()
const ISOweekStart = simple
if (dow <= 4) ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1)
else ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay())
return new Date(ISOweekStart.getFullYear(), ISOweekStart.getMonth(), ISOweekStart.getDate() + dayOfWeek)
const januaryFourth = new Date(year, 0, 4)
const startOfWeekOne = new Date(januaryFourth)
const dayOfWeekJan4 = (januaryFourth.getDay() + 6) % 7
startOfWeekOne.setDate(januaryFourth.getDate() - dayOfWeekJan4)
const targetDate = new Date(startOfWeekOne)
targetDate.setDate(startOfWeekOne.getDate() + (week - 1) * 7 + dayOfWeek)
return targetDate
}
// 样式工具函数
function getDayHeaderStyle(year: number, week: number, dayIndex: number, primaryColor: string, primaryColorSuppl: string) {
const isToday = isCurrentDay(year, week, dayIndex)
return {
marginBottom: '6px',
padding: '4px 8px',
fontSize: '13px',
fontWeight: '600',
background: isToday
? `linear-gradient(135deg, ${primaryColor}25 0%, ${primaryColor}40 100%)`
: `linear-gradient(135deg, ${primaryColorSuppl}15 0%, ${primaryColorSuppl}25 100%)`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: isToday ? `0 0 0 2px ${primaryColor}66` : undefined,
transition: 'all 0.3s ease',
}
}
</script>
<template>
<NEmpty v-if="(schedules?.length ?? 0) == 0" />
<NEmpty v-if="(schedules?.length ?? 0) === 0" />
<NList
v-else
style="padding: 0"
@@ -156,24 +189,25 @@ function getDateFromWeek(year: number, week: number, dayOfWeek: number): Date {
>
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
<div
:style="{
marginBottom: '6px',
padding: '4px 8px',
fontSize: '13px',
fontWeight: '600',
background: `linear-gradient(135deg, ${themeVars.primaryColorSuppl}15 0%, ${themeVars.primaryColorSuppl}25 100%)`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}"
:style="getDayHeaderStyle(item.year, item.week, index, themeVars.primaryColor, themeVars.primaryColorSuppl)"
>
<NTime
:time="getDateFromWeek(item.year, item.week, index)"
format="MM/dd"
:style="{ color: themeVars.primaryColor }"
:style="{
color: isCurrentDay(item.year, item.week, index) ? themeVars.primaryColor : themeVars.primaryColorSuppl,
fontWeight: isCurrentDay(item.year, item.week, index) ? '700' : '600',
}"
/>
<NText :style="{ fontWeight: isCurrentDay(item.year, item.week, index) ? '700' : '500' }">
{{ weekdays[index] }}
</NText>
<NBadge
v-if="isCurrentDay(item.year, item.week, index)"
dot
:color="themeVars.primaryColor"
:style="{ marginLeft: 'auto' }"
/>
<NText>{{ weekdays[index] }}</NText>
</div>
<div style="flex: 1; display: flex; flex-direction: column; min-height: 65px;">
<NCard
@@ -181,8 +215,10 @@ function getDateFromWeek(year: number, week: number, dayOfWeek: number): Date {
size="small"
:style="{
minHeight: '40px',
background: `linear-gradient(135deg, ${themeVars.cardColor} 0%, ${themeVars.bodyColor} 100%)`,
border: `1px dashed ${themeVars.dividerColor}`,
background: isCurrentDay(item.year, item.week, index)
? `linear-gradient(135deg, ${themeVars.primaryColorSuppl}08 0%, ${themeVars.primaryColorSuppl}15 100%)`
: `linear-gradient(135deg, ${themeVars.cardColor} 0%, ${themeVars.bodyColor} 100%)`,
border: `1px dashed ${isCurrentDay(item.year, item.week, index) ? themeVars.primaryColorSuppl : themeVars.dividerColor}`,
cursor: isSelf ? 'pointer' : 'default',
transition: 'all 0.2s ease',
}"

View File

@@ -203,85 +203,166 @@ const emptyCover = `${IMGUR_URL}None.png`
<style scoped>
.goods-card {
transition: all 0.3s ease;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.goods-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
transition: left 0.5s ease;
z-index: 1;
pointer-events: none;
}
.goods-card:hover::before {
left: 100%;
}
.goods-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-6px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1);
}
.goods-card:active {
transform: translateY(-4px);
transition: all 0.1s ease;
}
.pinned-card {
border: 2px solid var(--primary-color);
box-shadow: 0 2px 10px rgba(var(--primary-color-rgb), 0.15);
box-shadow: 0 4px 16px rgba(24, 160, 88, 0.25), 0 0 0 1px rgba(24, 160, 88, 0.1);
background: linear-gradient(135deg, var(--card-color) 0%, rgba(24, 160, 88, 0.02) 100%);
}
.pinned-card:hover {
box-shadow: 0 8px 28px rgba(24, 160, 88, 0.35), 0 2px 12px rgba(24, 160, 88, 0.15);
}
.cover-container {
position: relative;
max-height: 100%;
overflow: hidden;
}
.cover-container::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.1) 100%);
pointer-events: none;
z-index: 1;
}
/* 售罄遮罩效果 */
.goods-card:has(.tags-badge .n-tag[type="error"]) .cover-container::before {
content: '已售完';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5em;
font-weight: bold;
color: #ff5555;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
z-index: 3;
backdrop-filter: blur(2px);
}
.pin-badge {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--error-color);
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-color-hover) 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25), 0 0 0 2px rgba(255, 255, 255, 0.3);
color: white;
transform: rotate(45deg);
z-index: 2;
animation: pin-pulse 2s ease-in-out infinite;
}
@keyframes pin-pulse {
0%, 100% {
transform: rotate(45deg) scale(1);
}
50% {
transform: rotate(45deg) scale(1.05);
}
}
.price-badge {
position: absolute;
bottom: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
background: linear-gradient(135deg, rgba(24, 160, 88, 0.95) 0%, rgba(18, 130, 70, 0.95) 100%);
color: white;
padding: 4px 8px;
border-top-left-radius: 6px;
padding: 6px 12px;
border-top-left-radius: 8px;
z-index: 2;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
}
.tags-badge {
position: absolute;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
padding: 4px 8px;
border-top-right-radius: 6px;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.75) 0%, rgba(0, 0, 0, 0.6) 100%);
padding: 6px 8px;
border-top-right-radius: 8px;
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 70%;
max-width: 65%;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.price-text {
font-weight: bold;
font-size: 0.9em;
font-weight: 600;
font-size: 1em;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
letter-spacing: 0.5px;
}
.title-row {
margin-bottom: 4px;
margin-bottom: 8px;
gap: 8px;
}
.title-container {
max-width: 70%;
flex: 1;
min-width: 0;
}
.goods-title {
font-size: 1em;
line-height: 1.3;
font-size: 1.05em;
line-height: 1.4;
word-break: break-word;
font-weight: 600;
color: var(--text-color-1);
}
.content-section {
@@ -295,28 +376,36 @@ const emptyCover = `${IMGUR_URL}None.png`
.tags-container {
position: relative;
max-height: 40px;
max-height: 44px;
overflow: hidden;
margin-top: 4px;
margin-top: 8px;
}
.tags-wrapper {
display: flex;
flex-wrap: wrap;
gap: 4px;
gap: 6px;
align-items: center;
}
.user-tag {
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.85em;
}
.user-tag:hover {
transform: translateY(-2px);
transform: translateY(-2px) scale(1.05);
z-index: 1;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.stock-info {
font-size: 0.85em;
color: var(--text-color-3);
white-space: nowrap;
padding: 2px 8px;
background-color: var(--action-color);
border-radius: 4px;
font-weight: 500;
}
</style>

View File

@@ -1,4 +1,3 @@
import HyperDX from '@hyperdx/browser'
import EasySpeech from 'easy-speech'
import { createDiscreteApi, NButton, NFlex, NText } from 'naive-ui'
import { h } from 'vue'
@@ -50,6 +49,8 @@ export function InitVTsuru() {
async function InitOther() {
if (process.env.NODE_ENV !== 'development' && !window.$route.path.startsWith('/obs')) {
const mod = await import('@hyperdx/browser')
const HyperDX = (mod as any).default ?? mod
HyperDX.init({
apiKey: '7d1eb66c-24b8-445e-a406-dc2329fa9423',
service: 'vtsuru.live',
@@ -58,6 +59,8 @@ async function InitOther() {
advancedNetworkCapture: true, // Capture full HTTP request/response headers and bodies (default false)
ignoreUrls: [/localhost/i],
})
// 将实例挂到窗口,便于后续设置全局属性(可选)
;(window as any).__HyperDX__ = HyperDX
}
// 加载其他数据
InitTTS()
@@ -68,7 +71,8 @@ async function InitOther() {
if (account.value.biliUserAuthInfo && !useAuth.currentToken) {
useAuth.currentToken = account.value.biliUserAuthInfo.token
}
HyperDX.setGlobalAttributes({
const HyperDX = (window as any).__HyperDX__
HyperDX?.setGlobalAttributes({
userId: account.value.id.toString(),
userName: account.value.name,
})
@@ -141,7 +145,7 @@ function InitTTS() {
} else {
console.log('[SpeechSynthesis] 当前浏览器不支持tts服务')
}
} catch (e) {
} catch {
console.log('[SpeechSynthesis] 当前浏览器不支持tts服务')
}
}

View File

@@ -1,5 +1,4 @@
import { defineAsyncComponent, markRaw, ref } from 'vue'
import DefaultIndexTemplateVue from '@/views/view/indexTemplate/DefaultIndexTemplate.vue'
const debugAPI
= import.meta.env.VITE_API == 'dev'
@@ -126,7 +125,9 @@ export const IndexTemplateMap: TemplateMapType = {
'': {
name: '默认',
// settingName: 'Template.Index.Default',
component: markRaw(DefaultIndexTemplateVue),
component: markRaw(defineAsyncComponent(
async () => import('@/views/view/indexTemplate/DefaultIndexTemplate.vue'),
)),
},
}

View File

@@ -1,23 +1,10 @@
import { loader } from '@guolao/vue-monaco-editor'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import { InitVTsuru } from './data/Initializer'
import emitter from './mitt'
import router from './router'
import { isTauri } from './data/constants'
import { startHeartbeat } from './client/data/initialize'
loader.config({
'paths': {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor/min/vs',
},
'vs/nls': {
availableLanguages: {
'*': 'zh-cn',
},
},
})
// Monaco 的 worker 在编辑器组件中懒加载配置
const pinia = createPinia()
export const getPinia = () => pinia
@@ -25,9 +12,13 @@ export const getPinia = () => pinia
const app = createApp(App)
app.use(router).use(pinia).mount('#app')
InitVTsuru()
// 将初始化逻辑改为异步按需加载,避免把其依赖打入入口
void import('./data/Initializer').then(m => m.InitVTsuru())
// 本地化 isTauri避免在入口引入大量常量与模板映射
const isTauri = () => (window as any).__TAURI__ !== undefined || (window as any).__TAURI_INTERNAL__ !== undefined || '__TAURI__' in window
if (isTauri()) {
startHeartbeat();
// 仅在 Tauri 环境下才动态加载相关初始化,避免把 @tauri-apps/* 打入入口
void import('./client/data/initialize').then(m => m.startHeartbeat())
}
window.$mitt = emitter

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DanmujiConfig } from '../obs/DanmujiOBS.vue'
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import {
NButton,
NCard,
@@ -20,7 +20,7 @@ import {
NTabs,
useMessage,
} from 'naive-ui'
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import MonacoEditorComponent from '@/components/MonacoEditorComponent.vue'
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account'
import { EventDataTypes, GuardLevel } from '@/api/api-models'
import { CURRENT_HOST, defaultDanmujiCss } from '@/data/constants'
@@ -61,106 +61,35 @@ const guardLevelOptions = [
{ label: '总督', value: GuardLevel.Zongdu },
]
// 随机弹幕内容库
const randomMessages = [
'草草草草草',
'?????',
'来了来了',
'呜呜呜呜呜',
'寄!',
'笑死我了',
'这也太可爱了吧!',
'awsl',
'哈↑哈↑哈↑哈↑',
'前方高能',
'妈妈爱你',
'三连了!',
'给大佬递茶',
'答应我,别鸽了',
'好耶!',
'啊这',
'我超,好听!',
'永远的神!',
'给大家笑一个',
'555555',
'鸽子本鸽',
'主播牛逼',
'下次一定充钱',
'摸摸头',
'刚来,错过了什么',
'老板大气,老板身体健康',
'皮套萌萌哒',
'这个建模好精致',
'有没有录播组',
'狗头保命',
]
function randomDigits(length = 4) {
const min = length > 1 ? 10 ** (length - 1) : 0
const max = 10 ** length - 1
return Math.floor(Math.random() * (max - min + 1)) + min
}
// 随机用户名库
const randomUsernames = [
'嘉晚饭',
'嘉心糖',
'阿梓的狗',
'向晚大魔王',
'贝极星',
'泰文一',
'一个魂们',
'琳狼粉丝',
'乃贝时光',
'呜米小籽',
'柚恩家人',
'冰糖嘎嘣脆',
'柠宝推推',
'七海Nana7mi',
'鸟P',
'珈乐厨',
'珈乐时代',
'乃琳Queen',
'贝拉kira',
'小希厨子',
'阿夸单推',
'白上单推人',
'ぺこら推し',
'星街永远爱',
'吹雪的狗',
]
function generateTestUsername() {
return `测试用户${randomDigits(5)}`
}
// 随机礼物名库
const randomGifts = [
'小心心',
'告白气球',
'打call',
'奶茶',
'小花花',
'小星星',
'蛋糕',
'冰阔落',
'告白气球',
'比心',
'小电视',
'棒棒糖',
'荧光棒',
'小黄鸭',
'小飞船',
function generateTestMessage() {
const templates = [
'测试消息',
'这是一条测试消息',
'测试弹幕内容',
'系统测试消息',
'模拟展示消息',
]
const template = templates[Math.floor(Math.random() * templates.length)]
return `${template}${randomDigits(4)}`
}
// 随机粉丝牌名称库
const randomMedalNames = [
'魂组',
'DD団',
'天狗部',
'单推人',
'崩坏',
'幸运星',
'白上组',
'星街家',
'兔田团',
'夜空社',
'天使党',
'虹团',
'杏仁',
'梦追人',
'VVota',
]
function generateTestGiftName() {
return `测试礼物${randomDigits(3)}`
}
function generateTestMedalName() {
return `测试粉丝牌${randomDigits(3)}`
}
// 保存DanmujiConfig的配置
const danmujiConfig = useStorage<DanmujiConfig>('danmuji-config', {
@@ -236,7 +165,7 @@ function resetConfigToDefault() {
// 随机生成测试弹幕内容
function generateRandomContent() {
// 随机生成用户名
testFormData.uname = randomUsernames[Math.floor(Math.random() * randomUsernames.length)]
testFormData.uname = generateTestUsername()
// 随机生成用户ID (10000-99999)
testFormData.uid = Math.floor(Math.random() * 90000) + 10000
@@ -245,11 +174,11 @@ function generateRandomContent() {
switch (testFormData.type) {
case EventDataTypes.Message:
// 随机弹幕内容
testFormData.msg = randomMessages[Math.floor(Math.random() * randomMessages.length)]
testFormData.msg = generateTestMessage()
// 随机粉丝牌等级 (0-30)
testFormData.fans_medal_level = Math.floor(Math.random() * 31)
// 随机粉丝牌名称
testFormData.fans_medal_name = randomMedalNames[Math.floor(Math.random() * randomMedalNames.length)]
testFormData.fans_medal_name = generateTestMedalName()
// 随机舰长等级
const guardRandomIndex = Math.floor(Math.random() * guardLevelOptions.length)
testFormData.guard_level = guardLevelOptions[guardRandomIndex].value
@@ -257,7 +186,7 @@ function generateRandomContent() {
case EventDataTypes.Gift:
// 随机礼物名称
testFormData.msg = randomGifts[Math.floor(Math.random() * randomGifts.length)]
testFormData.msg = generateTestGiftName()
// 随机礼物数量 (1-99)
testFormData.num = Math.floor(Math.random() * 99) + 1
// 随机礼物价值 (1-50)
@@ -273,7 +202,7 @@ function generateRandomContent() {
case EventDataTypes.SC:
// 随机SC内容
testFormData.msg = randomMessages[Math.floor(Math.random() * randomMessages.length)]
testFormData.msg = generateTestMessage()
// 随机SC价格 (5-500)
testFormData.price = Math.floor(Math.random() * 496) + 5
break
@@ -432,7 +361,7 @@ function startAutoGenerate() {
// 为自动生成弹幕生成随机内容
function generateAutoContent() {
// 随机生成用户名
autoGenData.uname = randomUsernames[Math.floor(Math.random() * randomUsernames.length)]
autoGenData.uname = generateTestUsername()
// 随机生成用户ID (10000-99999)
autoGenData.uid = Math.floor(Math.random() * 90000) + 10000
@@ -441,11 +370,11 @@ function generateAutoContent() {
switch (autoGenData.type) {
case EventDataTypes.Message:
// 随机弹幕内容
autoGenData.msg = randomMessages[Math.floor(Math.random() * randomMessages.length)]
autoGenData.msg = generateTestMessage()
// 随机粉丝牌等级 (0-30)
autoGenData.fans_medal_level = Math.floor(Math.random() * 31)
// 随机粉丝牌名称
autoGenData.fans_medal_name = randomMedalNames[Math.floor(Math.random() * randomMedalNames.length)]
autoGenData.fans_medal_name = generateTestMedalName()
// 随机舰长等级
const guardRandomIndex = Math.floor(Math.random() * guardLevelOptions.length)
autoGenData.guard_level = guardLevelOptions[guardRandomIndex].value
@@ -453,7 +382,7 @@ function generateAutoContent() {
case EventDataTypes.Gift:
// 随机礼物名称
autoGenData.msg = randomGifts[Math.floor(Math.random() * randomGifts.length)]
autoGenData.msg = generateTestGiftName()
// 随机礼物数量 (1-99)
autoGenData.num = Math.floor(Math.random() * 99) + 1
// 随机礼物价值 (1-50)
@@ -469,7 +398,7 @@ function generateAutoContent() {
case EventDataTypes.SC:
// 随机SC内容
autoGenData.msg = randomMessages[Math.floor(Math.random() * randomMessages.length)]
autoGenData.msg = generateTestMessage()
// 随机SC价格 (5-500)
autoGenData.price = Math.floor(Math.random() * 496) + 5
break
@@ -663,7 +592,7 @@ async function uploadConfigToServer() {
确定要重设为默认CSS吗这将清除所有自定义样式。
</NPopconfirm>
</template>
<VueMonacoEditor
<MonacoEditorComponent
v-model:value="css"
language="css"
style="height: 400px; width: 100%;"

View File

@@ -622,8 +622,8 @@ watch(
:style="{ height: chartHeight }"
class="chart"
/>
<NDivider>
<NDivider />
<!-- <NDivider>
投稿播放量
<NDivider vertical />
<NTooltip>
@@ -665,7 +665,7 @@ watch(
:option="upstatLikeOption"
:style="{ height: chartHeight }"
class="chart"
/>
/> -->
</NSpace>
</NCard>
</template>

View File

@@ -7,7 +7,15 @@ import type {
ResponsePointGoodModel,
UploadPointGoodsModel,
} from '@/api/api-models'
import { Info24Filled } from '@vicons/fluent'
import {
Add24Filled,
ArrowSync24Filled,
Delete24Filled,
Edit24Filled,
Eye24Filled,
Info24Filled,
ShoppingBag24Filled,
} from '@vicons/fluent'
import { useRouteHash } from '@vueuse/router'
import {
NAlert,
@@ -517,14 +525,22 @@ onMounted(() => { })
>
<NButton
type="primary"
size="medium"
@click="onModalOpen"
>
<template #icon>
<NIcon :component="Add24Filled" />
</template>
添加礼物
</NButton>
<NButton
secondary
size="medium"
@click="$router.push({ name: 'user-goods', params: { id: accountInfo?.name } })"
>
<template #icon>
<NIcon :component="Eye24Filled" />
</template>
前往展示页
</NButton>
</NFlex>
@@ -549,29 +565,57 @@ onMounted(() => { })
class="point-goods-card"
>
<template #footer>
<NFlex :gap="8">
<NFlex
vertical
:gap="8"
style="width: 100%"
>
<NText style="font-size: 14px; color: var(--primary-color); font-weight: 500;">
<NIcon
:component="ShoppingBag24Filled"
style="vertical-align: -0.15em; margin-right: 4px"
/>
积分: {{ item.price }}
</NText>
<NFlex
justify="space-between"
:gap="8"
>
<NButton
type="info"
size="small"
style="flex: 1"
@click="onUpdateClick(item)"
>
<template #icon>
<NIcon :component="Edit24Filled" />
</template>
修改
</NButton>
<NButton
type="warning"
size="small"
style="flex: 1"
@click="onSetShelfClick(item, GoodsStatus.Discontinued)"
>
<template #icon>
<NIcon :component="ArrowSync24Filled" />
</template>
下架
</NButton>
<NButton
type="error"
size="small"
style="flex: 1"
@click="onDeleteClick(item)"
>
<template #icon>
<NIcon :component="Delete24Filled" />
</template>
删除
</NButton>
</NFlex>
</NFlex>
</template>
</PointGoodsItem>
</NGridItem>
@@ -605,7 +649,13 @@ onMounted(() => { })
:gap="8"
style="width: 100%"
>
<span>价格: {{ item.price }}</span>
<NText style="font-size: 14px; color: var(--primary-color); font-weight: 500;">
<NIcon
:component="ShoppingBag24Filled"
style="vertical-align: -0.15em; margin-right: 4px"
/>
积分: {{ item.price }}
</NText>
<NFlex
justify="space-between"
:gap="8"
@@ -613,22 +663,34 @@ onMounted(() => { })
<NButton
type="info"
size="small"
style="flex: 1"
@click="onUpdateClick(item)"
>
<template #icon>
<NIcon :component="Edit24Filled" />
</template>
修改
</NButton>
<NButton
type="success"
size="small"
style="flex: 1"
@click="onSetShelfClick(item, GoodsStatus.Normal)"
>
<template #icon>
<NIcon :component="ArrowSync24Filled" />
</template>
上架
</NButton>
<NButton
type="error"
size="small"
style="flex: 1"
@click="onDeleteClick(item)"
>
<template #icon>
<NIcon :component="Delete24Filled" />
</template>
删除
</NButton>
</NFlex>
@@ -1151,10 +1213,18 @@ onMounted(() => { })
height: 100%;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
border: 1px solid var(--border-color);
}
.point-goods-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.point-goods-card :deep(.n-card-header) {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.point-goods-card :deep(.n-card-content) {
@@ -1163,7 +1233,9 @@ onMounted(() => { })
}
.point-goods-card :deep(.n-card-footer) {
padding: 16px;
padding: 12px 16px;
background-color: var(--card-color);
border-top: 1px solid var(--border-color);
}
.goods-modal :deep(.n-card-header) {
@@ -1235,4 +1307,18 @@ onMounted(() => { })
align-items: center;
justify-content: center;
}
/* 按钮样式增强 */
.point-goods-card :deep(.n-button) {
font-weight: 500;
transition: all 0.2s ease;
}
.point-goods-card :deep(.n-button:hover) {
transform: translateY(-1px);
}
.point-goods-card :deep(.n-button:active) {
transform: translateY(0);
}
</style>

View File

@@ -71,6 +71,20 @@ const selectedItem = ref<DataTableRowKey[]>()
const targetStatus = ref<PointOrderStatus>()
const showStatusModal = ref(false)
// 订单统计
const orderStats = computed(() => {
return {
total: orders.value.length,
pending: orders.value.filter(o => o.status === PointOrderStatus.Pending).length,
shipped: orders.value.filter(o => o.status === PointOrderStatus.Shipped).length,
completed: orders.value.filter(o => o.status === PointOrderStatus.Completed).length,
physical: orders.value.filter(o => o.type === GoodsTypes.Physical).length,
virtual: orders.value.filter(o => o.type === GoodsTypes.Virtual).length,
totalPoints: orders.value.reduce((sum, o) => sum + o.point, 0),
filteredCount: filteredOrders.value.length,
}
})
// 获取所有订单
async function getOrders() {
try {
@@ -227,10 +241,72 @@ onMounted(async () => {
<template>
<NSpin :show="isLoading">
<NEmpty
v-if="orders.length == 0"
v-if="orders.length === 0"
description="暂无订单"
/>
<template v-else>
<!-- 统计卡片 -->
<NCard
size="small"
:bordered="false"
style="margin-bottom: 16px"
>
<NFlex
justify="space-around"
wrap
:gap="16"
>
<div class="stat-item">
<div class="stat-value">
{{ orderStats.total }}
</div>
<div class="stat-label">
总订单
</div>
</div>
<div class="stat-item">
<div class="stat-value warning">
{{ orderStats.pending }}
</div>
<div class="stat-label">
待发货
</div>
</div>
<div class="stat-item">
<div class="stat-value info">
{{ orderStats.shipped }}
</div>
<div class="stat-label">
已发货
</div>
</div>
<div class="stat-item">
<div class="stat-value success">
{{ orderStats.completed }}
</div>
<div class="stat-label">
已完成
</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ orderStats.physical }} / {{ orderStats.virtual }}
</div>
<div class="stat-label">
实体 / 虚拟
</div>
</div>
<div class="stat-item">
<div class="stat-value primary">
{{ orderStats.totalPoints }}
</div>
<div class="stat-label">
总积分
</div>
</div>
</NFlex>
</NCard>
<!-- 操作按钮 -->
<NFlex
:wrap="false"
@@ -393,4 +469,52 @@ onMounted(async () => {
.action-buttons {
margin: 12px 0;
}
.stat-item {
text-align: center;
min-width: 80px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--text-color-1);
margin-bottom: 4px;
}
.stat-value.primary {
color: var(--primary-color);
}
.stat-value.success {
color: var(--success-color);
}
.stat-value.info {
color: var(--info-color);
}
.stat-value.warning {
color: var(--warning-color);
}
.stat-label {
font-size: 12px;
color: var(--text-color-3);
}
/* 移动端优化 */
@media (max-width: 768px) {
.stat-item {
min-width: 70px;
}
.stat-value {
font-size: 20px;
}
.stat-label {
font-size: 11px;
}
}
</style>

View File

@@ -16,8 +16,6 @@ import {
NInputGroup,
NInputGroupLabel,
NInputNumber,
NList,
NListItem,
NModal,
NPopconfirm,
NRadioButton,
@@ -470,26 +468,42 @@ async function SaveComboSetting() {
justify="space-between"
align="center"
>
<span class="section-title">自定义礼物列表</span>
<span class="section-title">
自定义礼物列表
<NTag
v-if="Object.keys(setting.giftPercentMap).length > 0"
:bordered="false"
size="small"
type="info"
style="margin-left: 8px"
>
{{ Object.keys(setting.giftPercentMap).length }} 个礼物
</NTag>
</span>
<NButton
type="primary"
:disabled="!canEdit"
class="add-gift-button"
size="small"
@click="showAddGiftModal = true"
>
添加礼物
</NButton>
</NFlex>
<NList bordered>
<NEmpty
v-if="!Object.keys(setting.giftPercentMap).length"
description="暂无自定义礼物"
style="margin: 12px 0"
/>
<NListItem
<div
v-else
class="gift-list"
>
<div
v-for="item in Object.entries(setting.giftPercentMap)"
:key="item[0]"
class="gift-item"
>
<NFlex
align="center"
@@ -498,29 +512,34 @@ async function SaveComboSetting() {
>
<NFlex
align="center"
:gap="8"
:gap="12"
>
<NTag
:bordered="false"
size="medium"
type="success"
class="gift-name-tag"
>
{{ item[0] }}
</NTag>
<NText depth="2">
{{ setting.giftPercentMap[item[0]] }} 积分
</NText>
</NFlex>
<NFlex
align="center"
:gap="12"
:gap="8"
>
<NInputGroup
style="width: 180px"
style="width: 140px"
:disabled="!canEdit"
>
<NInputNumber
:value="setting.giftPercentMap[item[0]]"
:disabled="!canEdit"
min="0"
size="small"
@update:value="(v) => (setting.giftPercentMap[item[0]] = v ? v : 0)"
/>
<NButton
@@ -542,15 +561,14 @@ async function SaveComboSetting() {
<template #icon>
<NIcon :component="Delete24Regular" />
</template>
删除
</NButton>
</template>
确定要删除这个礼物吗?
</NPopconfirm>
</NFlex>
</NFlex>
</NListItem>
</NList>
</div>
</div>
</NFlex>
</NCard>
</NFlex>
@@ -656,6 +674,8 @@ async function SaveComboSetting() {
font-size: 16px;
font-weight: 500;
margin: 4px 0;
display: flex;
align-items: center;
}
.gift-card {
@@ -663,8 +683,29 @@ async function SaveComboSetting() {
margin-top: 8px;
}
.add-gift-button {
max-width: 120px;
.gift-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.gift-item {
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--card-color);
transition: all 0.3s ease;
}
.gift-item:hover {
background-color: var(--hover-color);
border-color: var(--primary-color-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.gift-name-tag {
font-weight: 500;
font-size: 14px;
}
.modal-input {
@@ -691,5 +732,13 @@ async function SaveComboSetting() {
flex-direction: column;
gap: 12px;
}
.gift-item {
padding: 10px;
}
.section-title {
font-size: 14px;
}
}
</style>

View File

@@ -5,7 +5,7 @@ import type {
import type { ResponsePointGoodModel, ResponsePointUserModel } from '@/api/api-models'
import { Info24Filled, Warning24Regular } from '@vicons/fluent'
import { useStorage } from '@vueuse/core'
import { useDebounceFn, useStorage } from '@vueuse/core'
import { format } from 'date-fns'
import { saveAs } from 'file-saver'
import {
@@ -31,7 +31,7 @@ import {
NTooltip,
useMessage,
} from 'naive-ui'
import { computed, h, onMounted, ref } from 'vue'
import { computed, h, onMounted, ref, watch } from 'vue'
import { useAccount } from '@/api/account'
import { QueryGetAPI } from '@/api/query'
import { POINT_API_URL } from '@/data/constants'
@@ -80,6 +80,20 @@ const RESET_CONFIRM_TEXT = '我确认删除'
// 用户数据
const users = ref<ResponsePointUserModel[]>([])
// 搜索关键词
const searchKeyword = ref('')
const debouncedSearchKeyword = ref('')
// 防抖搜索
const updateSearch = useDebounceFn((value: string) => {
debouncedSearchKeyword.value = value
}, 300)
watch(searchKeyword, (newVal) => {
updateSearch(newVal)
})
// 根据筛选条件过滤后的用户
const filteredUsers = computed(() => {
return users.value
@@ -90,11 +104,11 @@ const filteredUsers = computed(() => {
}
// 根据关键词搜索
if (settings.value.searchKeyword) {
const keyword = settings.value.searchKeyword.toLowerCase()
if (debouncedSearchKeyword.value) {
const keyword = debouncedSearchKeyword.value.toLowerCase()
return (
user.info.name?.toLowerCase().includes(keyword) == true
|| user.info.userId?.toString() == keyword
user.info.name?.toLowerCase().includes(keyword) === true
|| user.info.userId?.toString() === keyword
)
}
@@ -103,6 +117,18 @@ const filteredUsers = computed(() => {
.sort((a, b) => b.updateAt - a.updateAt) // 按更新时间降序排序
})
// 用户统计
const userStats = computed(() => {
return {
total: users.value.length,
authed: users.value.filter(u => u.isAuthed).length,
totalPoints: users.value.reduce((sum, u) => sum + u.point, 0),
totalOrders: users.value.reduce((sum, u) => sum + (u.orderCount || 0), 0),
avgPoints: users.value.length > 0 ? Math.round(users.value.reduce((sum, u) => sum + u.point, 0) / users.value.length) : 0,
filtered: filteredUsers.value.length,
}
})
// 当前查看的用户详情
const currentUser = ref<ResponsePointUserModel>()
@@ -383,6 +409,60 @@ onMounted(async () => {
:show="isLoading"
class="user-manage-container"
>
<!-- 统计卡片 -->
<NCard
size="small"
:bordered="false"
style="margin-bottom: 16px"
>
<NFlex
justify="space-around"
wrap
:gap="16"
>
<div class="stat-item">
<div class="stat-value">
{{ userStats.total }}
</div>
<div class="stat-label">
总用户
</div>
</div>
<div class="stat-item">
<div class="stat-value success">
{{ userStats.authed }}
</div>
<div class="stat-label">
已认证
</div>
</div>
<div class="stat-item">
<div class="stat-value primary">
{{ userStats.totalPoints }}
</div>
<div class="stat-label">
总积分
</div>
</div>
<div class="stat-item">
<div class="stat-value info">
{{ userStats.totalOrders }}
</div>
<div class="stat-label">
总订单
</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ userStats.avgPoints }}
</div>
<div class="stat-label">
平均积分
</div>
</div>
</NFlex>
</NCard>
<!-- 设置卡片 -->
<NCard title="设置">
<template #header-extra>
@@ -442,11 +522,16 @@ onMounted(async () => {
:gap="5"
>
<NInput
v-model:value="settings.searchKeyword"
v-model:value="searchKeyword"
placeholder="搜索用户 (用户名或UID)"
style="max-width: 200px"
style="width: 220px"
clearable
/>
size="small"
>
<template #prefix>
🔍
</template>
</NInput>
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
@@ -638,6 +723,35 @@ onMounted(async () => {
max-width: 300px;
}
.stat-item {
text-align: center;
min-width: 80px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--text-color-1);
margin-bottom: 4px;
}
.stat-value.primary {
color: var(--primary-color);
}
.stat-value.success {
color: var(--success-color);
}
.stat-value.info {
color: var(--info-color);
}
.stat-label {
font-size: 12px;
color: var(--text-color-3);
}
@media (max-width: 768px) {
.table-actions {
flex-direction: column;
@@ -647,5 +761,13 @@ onMounted(async () => {
.table-actions > * {
margin-bottom: 8px;
}
.stat-item {
min-width: 70px;
}
.stat-value {
font-size: 20px;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<script>
import { useDebounceFn } from '@vueuse/core'
import _ from 'lodash'
import { cloneDeep } from 'lodash-es'
import { defineComponent } from 'vue'
import * as constants from './constants'
import MembershipItem from './MembershipItem.vue'
@@ -51,7 +51,7 @@ export default defineComponent({
const customStyleElement = document.createElement('style')
document.head.appendChild(customStyleElement)
const setCssDebounce = useDebounceFn(() => {
customStyleElement.innerHTML = this.customCss ?? ''
customStyleElement.innerHTML = this.customCss || ''
console.log('[blivechat] 已设置自定义样式')
}, 1000)
return {
@@ -96,10 +96,10 @@ export default defineComponent({
canScrollToBottom(val) {
this.cantScrollStartTime = val ? null : new Date()
},
watchCustomCss: {
customCss: {
immediate: true,
handler(val, oldVal) {
this.setCssDebounce(val)
handler() {
this.setCssDebounce()
},
},
},
@@ -260,7 +260,7 @@ export default defineComponent({
this.enqueueIntervals.splice(0, this.enqueueIntervals.length - 5)
}
// 这边估计得尽量大只要不太早把消息缓冲发完就是平滑的。有MESSAGE_MAX_INTERVAL保底不会让消息延迟太大
// 其实可以用单调队列求最大值,偷懒不写了
// 使用Math.max计算最大值来估计下次入队时间间隔
this.estimatedEnqueueInterval = Math.max(...this.enqueueIntervals)
}
// 上次入队时间还是要设置,否则会太早把消息缓冲发完,然后较长时间没有新消息
@@ -280,10 +280,8 @@ export default defineComponent({
if (messageGroup.length > 0) {
if (this.smoothedMessageQueue.length > 0) {
// 和上一组合并
const lastMessageGroup = this.smoothedMessageQueue[this.smoothedMessageQueue.length - 1]
for (const message of messageGroup) {
lastMessageGroup.push(message)
}
const lastMessageGroup = this.smoothedMessageQueue.at(-1)
lastMessageGroup.push(...messageGroup)
} else {
// 自己一个组
this.smoothedMessageQueue.push(messageGroup)
@@ -325,12 +323,7 @@ export default defineComponent({
// 发消息
const messageGroups = this.smoothedMessageQueue.splice(0, groupNumToEmit)
const mergedGroup = []
for (const messageGroup of messageGroups) {
for (const message of messageGroup) {
mergedGroup.push(message)
}
}
const mergedGroup = messageGroups.flat()
this.handleMessageGroup(mergedGroup)
if (this.smoothedMessageQueue.length <= 0) {
@@ -386,7 +379,7 @@ export default defineComponent({
message.addTime = new Date()
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.unshift(_.cloneDeep(message))
this.paidMessages.unshift(cloneDeep(message))
const MAX_PAID_MESSAGE_NUM = 100
if (this.paidMessages.length > MAX_PAID_MESSAGE_NUM) {
this.paidMessages.splice(MAX_PAID_MESSAGE_NUM, this.paidMessages.length - MAX_PAID_MESSAGE_NUM)

View File

@@ -181,7 +181,15 @@ export function getShowRichContent(message) {
}
export function getShowContentParts(message) {
const contentParts = [...message.contentParts || []]
const contentParts = Array.isArray(message.contentParts)
? [...message.contentParts]
: []
if (contentParts.length === 0 && message.content) {
contentParts.push({
type: CONTENT_TYPE_TEXT,
text: message.content,
})
}
if (message.translation) {
contentParts.push({
type: CONTENT_TYPE_TEXT,

View File

@@ -28,10 +28,10 @@ import {
NText,
NTree,
} from 'naive-ui'
import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { controllerBodies, controllerStructures, gamepadConfigs } from '@/data/gamepadConfigs'
import { useGamepadStore } from '@/store/useGamepadStore'
import GamepadDisplay from './GamepadDisplay.vue'
const GamepadDisplay = defineAsyncComponent(() => import('./GamepadDisplay.vue'))
interface Props {
viewBox?: string
@@ -90,6 +90,8 @@ const bodyOptions = computed(() => availableBodies.value.map(body => ({
const bodyIdStorageKey = computed(() => `gamepad-body-${selectedType.value}`)
const selectedBodyId = useStorage<string>(bodyIdStorageKey, '')
// 是否显示实时预览(避免首次进入即加载大体积渲染资源)
const showPreview = ref(false)
// 当手柄类型变化时重置相关配置
watch(selectedType, () => {
@@ -423,7 +425,12 @@ const gamepadDisplayUrl = computed(() => {
size="small"
style="margin-top: 10px;"
>
<div style="position: relative; width: 100%; height: 300px; background-color: #333; border-radius: 8px; overflow: hidden;">
<NSpace align="center" size="small" style="margin-bottom: 6px;">
<NButton size="small" type="info" @click="showPreview = !showPreview">
{{ showPreview ? '隐藏预览' : '显示预览' }}
</NButton>
</NSpace>
<div v-if="showPreview" style="position: relative; width: 100%; height: 300px; background-color: #333; border-radius: 8px; overflow: hidden;">
<GamepadDisplay
:key="selectedType"
:type="selectedType"

View File

@@ -9,6 +9,7 @@ import type {
ResponsePointOrder2UserModel,
UserInfo,
} from '@/api/api-models'
import { useDebounceFn } from '@vueuse/core'
import {
NAlert,
NButton,
@@ -31,7 +32,7 @@ import {
useDialog,
useMessage,
} from 'naive-ui'
import { computed, h, onMounted, ref } from 'vue'
import { computed, h, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
GoodsTypes,
@@ -73,6 +74,16 @@ const onlyCanBuy = ref(false) // 只显示可兑换
const ignoreGuard = ref(false) // 忽略舰长限制
const sortOrder = ref<string | null>(null) // 排序方式
const searchKeyword = ref('') // 搜索关键词
const debouncedSearchKeyword = ref('') // 防抖后的搜索关键词
// 防抖搜索
const updateSearch = useDebounceFn((value: string) => {
debouncedSearchKeyword.value = value
}, 300)
watch(searchKeyword, (newVal) => {
updateSearch(newVal)
})
// --- 计算属性 ---
@@ -117,9 +128,9 @@ const selectedItems = computed(() => {
// 关键词搜索 (匹配名称或描述)
.filter(
item =>
!searchKeyword.value
|| item.name.toLowerCase().includes(searchKeyword.value.toLowerCase())
|| (item.description && item.description.toLowerCase().includes(searchKeyword.value.toLowerCase())),
!debouncedSearchKeyword.value
|| item.name.toLowerCase().includes(debouncedSearchKeyword.value.toLowerCase())
|| (item.description && item.description.toLowerCase().includes(debouncedSearchKeyword.value.toLowerCase())),
)
// 应用排序方式
@@ -163,12 +174,6 @@ const selectedItems = computed(() => {
})
})
// 获取商品标签颜色
function getTagColor(index: number): 'default' | 'info' | 'success' | 'warning' | 'error' | 'primary' {
const colors: Array<'default' | 'info' | 'success' | 'warning' | 'error' | 'primary'> = ['default', 'info', 'success', 'warning', 'error']
return colors[index % colors.length]
}
// --- 方法 ---
// 获取礼物兑换按钮的提示文本
@@ -746,7 +751,7 @@ onMounted(async () => {
}
.filter-section {
padding: 12px 16px;
padding: 10px;
background-color: var(--action-color);
}
@@ -777,22 +782,25 @@ onMounted(async () => {
.search-filter-row {
gap: 12px;
flex-wrap: wrap;
}
.search-input {
max-width: 200px;
min-width: 180px;
max-width: 250px;
flex: 1 1 200px;
}
.filter-options {
gap: 16px;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.filter-checkbox {
margin: 0;
}
.sort-select {
width: 120px;
white-space: nowrap;
}
.goods-list-container {
@@ -807,25 +815,64 @@ onMounted(async () => {
.goods-item {
break-inside: avoid;
background-color: var(--card-color);
transition: all 0.3s ease-in-out;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
position: relative;
overflow: hidden;
margin: 0 auto;
}
.goods-item:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
.goods-item::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
transition: left 0.6s ease;
z-index: 1;
pointer-events: none;
}
.goods-item:hover::before {
left: 100%;
}
.goods-item:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
z-index: 2;
border-color: var(--primary-color-hover);
}
.goods-item:active {
transform: translateY(-6px) scale(1.01);
transition: all 0.1s ease;
}
.pinned-item {
border: 2px solid var(--primary-color);
box-shadow: 0 2px 12px rgba(var(--primary-color-rgb), 0.15);
box-shadow: 0 4px 20px rgba(24, 160, 88, 0.25), 0 0 0 1px rgba(24, 160, 88, 0.1);
position: relative;
background: linear-gradient(135deg, var(--card-color) 0%, rgba(24, 160, 88, 0.04) 100%);
animation: subtle-glow 3s ease-in-out infinite;
}
.pinned-item:hover {
box-shadow: 0 12px 32px rgba(24, 160, 88, 0.35), 0 4px 16px rgba(24, 160, 88, 0.2);
border-color: var(--primary-color-hover);
}
@keyframes subtle-glow {
0%, 100% {
box-shadow: 0 4px 20px rgba(24, 160, 88, 0.25), 0 0 0 1px rgba(24, 160, 88, 0.1);
}
50% {
box-shadow: 0 6px 24px rgba(24, 160, 88, 0.35), 0 0 0 2px rgba(24, 160, 88, 0.15);
}
}
.pinned-item::before {
@@ -897,16 +944,29 @@ onMounted(async () => {
}
.goods-footer {
padding: 8px;
border-top: 1px solid var(--border-color-1);
background-color: rgba(var(--card-color-rgb), 0.7);
padding: 10px 12px;
border-top: 1px solid var(--border-color);
background: linear-gradient(to bottom, rgba(var(--card-color-rgb), 0.5), var(--card-color));
backdrop-filter: blur(2px);
}
.exchange-btn {
min-width: 80px;
min-width: 90px;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 600;
box-shadow: 0 2px 6px rgba(24, 160, 88, 0.2);
}
.exchange-btn:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(24, 160, 88, 0.35);
}
.exchange-btn:not(:disabled):active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(24, 160, 88, 0.2);
}
.exchange-btn::after {
@@ -919,7 +979,7 @@ onMounted(async () => {
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.3),
transparent
);
transition: all 0.6s ease;

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { ResponsePointOrder2UserModel } from '@/api/api-models'
import { NButton, NEmpty, NFlex, NSpin, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { NButton, NCard, NDataTable, NEmpty, NFlex, NSelect, NSpin, NTag, useMessage } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { PointOrderStatus } from '@/api/api-models'
import PointOrderCard from '@/components/manage/PointOrderCard.vue'
import { POINT_API_URL } from '@/data/constants'
import { useBiliAuth } from '@/store/useBiliAuth'
@@ -14,6 +15,42 @@ const useAuth = useBiliAuth()
const orders = ref<ResponsePointOrder2UserModel[]>([])
const isLoading = ref(false)
// 筛选状态
const statusFilter = ref<PointOrderStatus | null>(null)
const searchKeyword = ref('')
// 筛选后的订单
const filteredOrders = computed(() => {
let result = orders.value
// 状态筛选
if (statusFilter.value !== null) {
result = result.filter(order => order.status === statusFilter.value)
}
// 搜索关键词筛选
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(order =>
order.goods?.name.toLowerCase().includes(keyword)
|| order.id.toString().includes(keyword),
)
}
return result
})
// 订单统计
const orderStats = computed(() => {
return {
total: orders.value.length,
pending: orders.value.filter(o => o.status === PointOrderStatus.Pending).length,
shipped: orders.value.filter(o => o.status === PointOrderStatus.Shipped).length,
completed: orders.value.filter(o => o.status === PointOrderStatus.Completed).length,
totalPoints: orders.value.reduce((sum, o) => sum + o.point, 0),
}
})
async function getOrders() {
try {
isLoading.value = true
@@ -53,20 +90,137 @@ onMounted(async () => {
<template>
<NSpin :show="isLoading">
<NFlex justify="end" style="margin-bottom: 10px">
<NButton size="small" type="primary" @click="getOrders">
<!-- 统计卡片 -->
<NCard
size="small"
:bordered="false"
style="margin-bottom: 16px"
>
<NFlex
justify="space-around"
wrap
:gap="16"
>
<div class="stat-item">
<div class="stat-value">
{{ orderStats.total }}
</div>
<div class="stat-label">
总订单
</div>
</div>
<div class="stat-item">
<div class="stat-value warning">
{{ orderStats.pending }}
</div>
<div class="stat-label">
待发货
</div>
</div>
<div class="stat-item">
<div class="stat-value info">
{{ orderStats.shipped }}
</div>
<div class="stat-label">
已发货
</div>
</div>
<div class="stat-item">
<div class="stat-value success">
{{ orderStats.completed }}
</div>
<div class="stat-label">
已完成
</div>
</div>
<div class="stat-item">
<div class="stat-value primary">
{{ orderStats.totalPoints }}
</div>
<div class="stat-label">
总积分
</div>
</div>
</NFlex>
</NCard>
<!-- 筛选和搜索 -->
<NFlex
justify="space-between"
align="center"
style="margin-bottom: 12px"
wrap
:gap="12"
>
<NFlex
:gap="12"
wrap
>
<NSelect
v-model:value="statusFilter"
:options="[
{ label: '全部状态', value: null as any },
{ label: '待发货', value: PointOrderStatus.Pending },
{ label: '已发货', value: PointOrderStatus.Shipped },
{ label: '已完成', value: PointOrderStatus.Completed },
]"
style="width: 120px"
size="small"
/>
</NFlex>
<NButton
size="small"
type="primary"
@click="getOrders"
>
刷新订单
</NButton>
</NFlex>
<NEmpty
v-if="orders.length == 0"
v-if="filteredOrders.length === 0"
description="暂无订单"
/>
<PointOrderCard
v-else
:order="orders"
:order="filteredOrders"
:loading="isLoading"
type="user"
/>
</NSpin>
</template>
<style scoped>
.stat-item {
text-align: center;
min-width: 80px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--text-color-1);
margin-bottom: 4px;
}
.stat-value.primary {
color: var(--primary-color);
}
.stat-value.success {
color: var(--success-color);
}
.stat-value.info {
color: var(--info-color);
}
.stat-value.warning {
color: var(--warning-color);
}
.stat-label {
font-size: 12px;
color: var(--text-color-3);
}
</style>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import type { ResponsePointHisrotyModel } from '@/api/api-models'
import { NButton, NEmpty, NFlex, NSelect, NSpin, useMessage } from 'naive-ui'
import { format } from 'date-fns'
import { saveAs } from 'file-saver'
import { NButton, NCard, NDatePicker, NEmpty, NFlex, NRadioButton, NRadioGroup, NSelect, NSpin, NStatistic, useMessage } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { PointFrom } from '@/api/api-models'
import PointHistoryCard from '@/components/manage/PointHistoryCard.vue'
import { POINT_API_URL } from '@/data/constants'
import { useBiliAuth } from '@/store/useBiliAuth'
import { objectsToCSV } from '@/Utils'
// 定义加载完成的事件
const emit = defineEmits(['dataLoaded'])
@@ -15,6 +18,21 @@ const isLoading = ref(false)
const history = ref<ResponsePointHisrotyModel[]>([])
const streamerFilter = ref<string | null>('')
const pointTypeFilter = ref<'all' | 'increase' | 'decrease'>('all')
const dateRange = ref<[number, number] | null>(null)
// 积分历史统计
const historyStats = computed(() => {
return {
total: history.value.length,
totalIncrease: history.value.filter(h => h.point > 0).reduce((sum, h) => sum + h.point, 0),
totalDecrease: Math.abs(history.value.filter(h => h.point < 0).reduce((sum, h) => sum + h.point, 0)),
fromManual: history.value.filter(h => h.from === PointFrom.Manual).length,
fromDanmaku: history.value.filter(h => h.from === PointFrom.Danmaku).length,
fromCheckIn: history.value.filter(h => h.from === PointFrom.CheckIn).length,
fromUse: history.value.filter(h => h.from === PointFrom.Use).length,
}
})
// 获取积分历史记录
async function getHistories() {
@@ -58,10 +76,11 @@ onMounted(async () => {
// 根据主播名称筛选历史记录
const filteredHistory = computed(() => {
if (streamerFilter.value === '' || streamerFilter.value === null) {
return history.value
}
return history.value.filter((item) => {
let result = history.value
// 主播筛选
if (streamerFilter.value && streamerFilter.value !== '') {
result = result.filter((item) => {
// 只筛选主播操作、弹幕来源和签到
if ([PointFrom.Manual, PointFrom.Danmaku, PointFrom.CheckIn].includes(item.from)) {
// 精确匹配主播名称
@@ -74,6 +93,21 @@ const filteredHistory = computed(() => {
// 其他类型的记录,在筛选时隐藏
return false
})
}
// 积分类型筛选
if (pointTypeFilter.value === 'increase') {
result = result.filter(h => h.point > 0)
} else if (pointTypeFilter.value === 'decrease') {
result = result.filter(h => h.point < 0)
}
// 时间范围筛选
if (dateRange.value && dateRange.value[0] && dateRange.value[1]) {
result = result.filter(h => h.createAt >= dateRange.value![0] && h.createAt <= dateRange.value![1])
}
return result
})
// 计算可选的主播列表
@@ -91,14 +125,107 @@ const streamerOptions = computed(() => {
})
return options
})
// 导出积分历史数据
function exportHistoryData() {
try {
const pointFromText = {
[PointFrom.Manual]: '手动调整',
[PointFrom.Danmaku]: '弹幕',
[PointFrom.CheckIn]: '签到',
[PointFrom.Use]: '使用积分',
}
const text = objectsToCSV(
filteredHistory.value.map((item) => {
return {
时间: format(item.createAt, 'yyyy-MM-dd HH:mm:ss'),
积分变化: item.point,
来源: pointFromText[item.from] || '未知',
主播: item.extra?.user?.name || '-',
数量: item.count || '-',
备注: item.extra?.reason || '-',
}
}),
)
// 添加BOM标记确保Excel正确识别UTF-8编码
const BOM = new Uint8Array([0xEF, 0xBB, 0xBF])
const utf8encoder = new TextEncoder()
const utf8array = utf8encoder.encode(text)
saveAs(
new Blob([BOM, utf8array], { type: 'text/csv;charset=utf-8;' }),
`积分历史_${format(Date.now(), 'yyyy-MM-dd_HH-mm-ss')}.csv`,
)
message.success('导出成功')
} catch (error) {
message.error(`导出失败: ${error}`)
console.error('导出失败:', error)
}
}
</script>
<template>
<NSpin :show="isLoading">
<!-- 统计卡片 -->
<NCard
size="small"
:bordered="false"
style="margin-bottom: 16px"
>
<NFlex
justify="end"
justify="space-around"
wrap
:gap="16"
>
<div class="stat-item">
<div class="stat-value">
{{ historyStats.total }}
</div>
<div class="stat-label">
总记录
</div>
</div>
<div class="stat-item">
<div class="stat-value success">
+{{ historyStats.totalIncrease }}
</div>
<div class="stat-label">
总获得
</div>
</div>
<div class="stat-item">
<div class="stat-value error">
-{{ historyStats.totalDecrease }}
</div>
<div class="stat-label">
总消耗
</div>
</div>
<div class="stat-item">
<div class="stat-value primary">
{{ historyStats.totalIncrease - historyStats.totalDecrease }}
</div>
<div class="stat-label">
净增加
</div>
</div>
</NFlex>
</NCard>
<!-- 筛选和搜索 -->
<NFlex
justify="space-between"
align="center"
style="margin-bottom: 10px"
style="margin-bottom: 12px"
wrap
:gap="12"
>
<NFlex
:gap="12"
wrap
>
<NSelect
v-model:value="streamerFilter"
@@ -106,8 +233,40 @@ const streamerOptions = computed(() => {
placeholder="按主播筛选"
clearable
size="small"
style="max-width: 200px; margin-right: 10px"
style="min-width: 120px; max-width: 180px"
/>
<NRadioGroup
v-model:value="pointTypeFilter"
size="small"
>
<NRadioButton value="all">
全部
</NRadioButton>
<NRadioButton value="increase">
增加
</NRadioButton>
<NRadioButton value="decrease">
减少
</NRadioButton>
</NRadioGroup>
<NDatePicker
v-model:value="dateRange"
type="datetimerange"
clearable
size="small"
style="max-width: 360px"
placeholder="选择时间范围"
/>
</NFlex>
<NFlex :gap="8">
<NButton
size="small"
type="info"
secondary
@click="exportHistoryData"
>
导出数据
</NButton>
<NButton
size="small"
type="primary"
@@ -116,8 +275,10 @@ const streamerOptions = computed(() => {
刷新记录
</NButton>
</NFlex>
</NFlex>
<NEmpty
v-if="filteredHistory.length == 0"
v-if="filteredHistory.length === 0"
description="暂无符合条件的积分记录"
/>
<PointHistoryCard
@@ -126,3 +287,34 @@ const streamerOptions = computed(() => {
/>
</NSpin>
</template>
<style scoped>
.stat-item {
text-align: center;
min-width: 80px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--text-color-1);
margin-bottom: 4px;
}
.stat-value.primary {
color: var(--primary-color);
}
.stat-value.success {
color: var(--success-color);
}
.stat-value.error {
color: var(--error-color);
}
.stat-label {
font-size: 12px;
color: var(--text-color-3);
}
</style>

View File

@@ -327,7 +327,12 @@ onMounted(async () => {
justify="center"
>
<div style="max-width: 95vw; width: 1200px">
<NCard title="我的信息">
<NCard
title="我的信息"
:bordered="false"
size="small"
class="info-card"
>
<NDescriptions
label-placement="left"
bordered
@@ -350,24 +355,27 @@ onMounted(async () => {
<NTag
v-if="biliAuth.id > 0"
type="success"
size="small"
>
已认证
</NTag>
<NTag
v-else
type="error"
size="small"
>
未认证
</NTag>
</NDescriptionsItem>
</NDescriptions>
</NCard>
<NDivider />
<NDivider style="margin: 16px 0" />
<NTabs
v-if="hash"
v-model:value="hash"
default-value="points"
animated
type="line"
@update:value="onTabChange"
>
<NTabPane
@@ -440,3 +448,54 @@ onMounted(async () => {
</template>
</NLayout>
</template>
<style scoped>
.info-card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.info-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:deep(.n-tabs-nav) {
padding: 0 12px;
}
:deep(.n-tabs-tab) {
transition: all 0.3s ease;
}
:deep(.n-tabs-tab:hover) {
color: var(--primary-color);
}
:deep(.n-tab-pane) {
padding-top: 16px;
}
/* 移动端优化 */
@media (max-width: 768px) {
:deep(.n-layout-header) {
padding: 8px !important;
}
:deep(.n-layout-content) {
padding: 16px 8px !important;
}
:deep(.n-descriptions) {
font-size: 13px;
}
:deep(.n-tabs-nav) {
padding: 0 4px;
}
}
/* 加载动画优化 */
:deep(.n-spin-container) {
min-height: 200px;
}
</style>

View File

@@ -274,33 +274,48 @@ defineExpose({
<NFlex
justify="center"
align="center"
vertical
:gap="16"
>
<NCard
title="更多"
embedded
style="width: 100%; max-width: 800px"
>
<NCollapse>
<NCollapseItem
title="收货地址"
name="1"
>
<NFlex vertical>
<NFlex
vertical
:gap="12"
>
<NButton
type="primary"
block
@click="onOpenAddressModal"
>
添加地址
</NButton>
<NEmpty
v-if="!biliAuth.address || biliAuth.address.length === 0"
description="暂无收货地址"
style="margin: 20px 0"
/>
<NList
v-else
size="small"
bordered
>
<NListItem
v-for="address in biliAuth.address"
:key="address.id"
class="address-item"
>
<AddressDisplay :address="address">
<template #actions>
<NFlex :gap="8">
<NButton
size="small"
type="info"
@@ -323,6 +338,7 @@ defineExpose({
</template>
确定要删除这个收货信息吗?
</NPopconfirm>
</NFlex>
</template>
</AddressDisplay>
</NListItem>
@@ -333,49 +349,81 @@ defineExpose({
title="登录链接"
name="2"
>
<NFlex
vertical
:gap="8"
>
<NText depth="3">
使用此链接可以直接登录到您的账号
</NText>
<NInput
type="textarea"
:value="`${CURRENT_HOST}bili-user?auth=${useAuth.biliToken}`"
readonly
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</NFlex>
</NCollapseItem>
</NCollapse>
</NCard>
<NCard
title="账号操作"
embedded
style="width: 100%; max-width: 800px"
>
<NFlex
vertical
:gap="12"
>
<NFlex>
<NPopconfirm @positive-click="logout">
<template #trigger>
<NButton
type="warning"
size="small"
>
登出
登出当前账号
</NButton>
</template>
确定要登出吗?
</NPopconfirm>
</NFlex>
<NDivider> 切换账号 </NDivider>
<NDivider style="margin: 8px 0">
切换账号
</NDivider>
<NEmpty
v-if="useAuth.biliTokens.length === 0"
description="暂无其他账号"
/>
<NList
v-else
clickable
bordered
>
<NListItem
v-for="item in useAuth.biliTokens"
:key="item.token"
class="account-item"
:class="{ 'current-account': useAuth.biliToken === item.token }"
@click="switchAuth(item.token)"
>
<NFlex align="center">
<NFlex
align="center"
justify="space-between"
style="width: 100%"
>
<NFlex
align="center"
:gap="8"
>
<NTag
v-if="useAuth.biliToken == item.token"
type="info"
v-if="useAuth.biliToken === item.token"
type="success"
size="small"
>
当前账号
</NTag>
<NText strong>
{{ item.name }}
</NText>
<NDivider
vertical
style="margin: 0"
@@ -384,8 +432,10 @@ defineExpose({
{{ item.uId }}
</NText>
</NFlex>
</NFlex>
</NListItem>
</NList>
</NFlex>
</NCard>
</NFlex>
</NSpin>
@@ -403,18 +453,23 @@ defineExpose({
ref="formRef"
:model="currentAddress"
:rules="rules"
label-placement="top"
>
<NFormItem
label=""
label="区选择"
path="area"
required
>
<NFlex style="width: 100%">
<NFlex
style="width: 100%"
:gap="8"
wrap
>
<NSelect
v-model:value="currentAddress.province"
:options="provinceOptions"
placeholder="请选择"
style="width: 100px"
placeholder=""
style="flex: 1; min-width: 100px"
filterable
@update:value="onAreaSelectChange(0)"
/>
@@ -423,8 +478,8 @@ defineExpose({
v-model:value="currentAddress.city"
:options="cityOptions(currentAddress.province)"
:disabled="!currentAddress?.province"
placeholder="请选择"
style="width: 100px"
placeholder=""
style="flex: 1; min-width: 100px"
filterable
@update:value="onAreaSelectChange(1)"
/>
@@ -433,8 +488,8 @@ defineExpose({
v-model:value="currentAddress.district"
:options="currentAddress.city ? districtOptions(currentAddress.province, currentAddress.city) : []"
:disabled="!currentAddress?.city"
placeholder="请选择"
style="width: 100px"
placeholder=""
style="flex: 1; min-width: 100px"
filterable
@update:value="onAreaSelectChange(2)"
/>
@@ -443,8 +498,8 @@ defineExpose({
v-model:value="currentAddress.street"
:options="currentAddress.city && currentAddress.district ? streetOptions(currentAddress.province, currentAddress.city, currentAddress.district) : []"
:disabled="!currentAddress?.district"
placeholder="请选择街道"
style="width: 150px"
placeholder="街道"
style="flex: 1; min-width: 120px"
filterable
/>
</NFlex>
@@ -456,39 +511,43 @@ defineExpose({
>
<NInput
v-model:value="currentAddress.address"
placeholder="详细地址"
placeholder="请输入详细地址楼栋号单元号门牌号等"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</NFormItem>
<NFlex :gap="12">
<NFormItem
label="联系电话"
path="phone"
required
style="flex: 1"
>
<NInputNumber
v-model:value="currentAddress.phone"
placeholder="联系电话"
placeholder="请输入联系电话"
:show-button="false"
style="width: 200px"
style="width: 100%"
/>
</NFormItem>
<NFormItem
label="联系人"
path="name"
required
style="flex: 1"
>
<NInput
v-model:value="currentAddress.name"
placeholder="联系人"
style="max-width: 150px"
placeholder="请输入联系人姓名"
/>
</NFormItem>
</NFlex>
<NFormItem
label="用户协议"
required
>
<NCheckbox v-model:checked="userAgree">
阅读并同意本站
我已阅读并同意本站
<NButton
text
type="info"
@@ -498,13 +557,23 @@ defineExpose({
</NButton>
</NCheckbox>
</NFormItem>
<NFlex
justify="end"
:gap="12"
>
<NButton
type="info"
@click="showAddressModal = false"
>
取消
</NButton>
<NButton
type="primary"
:loading="isLoading"
@click="updateAddress"
>
保存
</NButton>
</NFlex>
</NForm>
</NSpin>
</NModal>
@@ -519,3 +588,37 @@ defineExpose({
</NScrollbar>
</NModal>
</template>
<style scoped>
.address-item {
transition: all 0.3s ease;
}
.address-item:hover {
background-color: var(--hover-color);
}
.account-item {
transition: all 0.3s ease;
cursor: pointer;
}
.account-item:hover {
background-color: var(--hover-color);
}
.current-account {
background-color: var(--primary-color-hover);
}
/* 移动端优化 */
@media (max-width: 768px) {
:deep(.n-card) {
margin: 0 8px;
}
:deep(.n-form-item-label) {
font-size: 14px;
}
}
</style>

View File

@@ -9,6 +9,7 @@ import Markdown from 'unplugin-vue-markdown/vite'
import { defineConfig } from 'vite'
import svgLoader from 'vite-svg-loader'
import { VineVitePlugin } from 'vue-vine/vite'
// import MonacoEditorNlsPlugin, { esbuildPluginMonacoEditorNls, Languages } from 'vite-plugin-monaco-editor-nls'
// 自定义SVGO插件删除所有名称以sodipodi:和inkscape:开头的元素
const removeSodipodiInkscape = {
@@ -87,6 +88,8 @@ export default defineConfig({
include: [/\.vue$/, /\.vue\?vue/, /\.md$/, /\.vine$/],
}),
VineVitePlugin(),
// Monaco 中文本地化
// MonacoEditorNlsPlugin({ locale: Languages.zh_hans }),
],
server: { port: 51000 },
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
@@ -97,9 +100,15 @@ export default defineConfig({
},
optimizeDeps: {
include: ['@vicons/fluent', '@vicons/ionicons5', 'vue', 'vue-router'],
esbuildOptions: {
// plugins: [
// esbuildPluginMonacoEditorNls({ locale: Languages.zh_hans }),
// ],
},
},
build: {
sourcemap: true,
// 生产环境建议关闭以减少产物体积与网络请求
sourcemap: false,
target: 'esnext',
minify: 'oxc',
chunkSizeWarningLimit: 1000,
@@ -117,6 +126,37 @@ export default defineConfig({
test: /[\\/]node_modules[\\/](naive-ui|@vueuse[\\/]core)[\\/]/,
priority: -10,
},
// 精细化切分大体积依赖,提升缓存与首屏体积可控性
{
name: 'echarts-vendor',
test: /[\\/]node_modules[\\/](echarts|zrender|vue-echarts)[\\/]/,
priority: -20,
},
{
name: 'wangeditor-vendor',
test: /[\\/]node_modules[\\/]@wangeditor[\\/]/,
priority: -20,
},
{
name: 'hyperdx-vendor',
test: /[\\/]node_modules[\\/]@hyperdx[\\/]/,
priority: -20,
},
{
name: 'xlsx-vendor',
test: /[\\/]node_modules[\\/]xlsx[\\/]/,
priority: -20,
},
{
name: 'jszip-vendor',
test: /[\\/]node_modules[\\/]jszip[\\/]/,
priority: -20,
},
{
name: 'html2canvas-vendor',
test: /[\\/]node_modules[\\/]html2canvas[\\/]/,
priority: -20,
},
],
},
},