Compare commits

..

4 Commits

Author SHA1 Message Date
fanbook-wangdage
29409e3e95 添加用户列表的高级筛选功能 2026-02-10 20:20:40 +08:00
fanbook-wangdage
e58057665b 下载页面支持显示测试版 2026-02-10 12:58:41 +08:00
fanbook-wangdage
47a4c63de2 msix是使用zip打包的 2026-02-08 13:47:20 +08:00
fanbook-wangdage
7e08de8250 添加下载页面和下载资源管理后台 2026-02-05 21:52:41 +08:00
13 changed files with 2329 additions and 92 deletions

20
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "ht-web",
"version": "0.0.0",
"dependencies": {
"@primer/css": "^22.1.0",
"@tsparticles/slim": "^3.9.1",
"@tsparticles/vue3": "^3.0.1",
"axios": "^1.13.2",
@@ -1082,6 +1083,25 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@primer/css": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/@primer/css/-/css-22.1.0.tgz",
"integrity": "sha512-Nwg9QaRiBeu0BU6h+Su0X07daihX1obiuqGRG8y+SexOnvWhN2J5n4OFAvGfQsit07Y7Q6gGoK+yVU5tb8CtDA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@primer/primitives": "10.x || 11.x"
}
},
"node_modules/@primer/primitives": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-11.4.0.tgz",
"integrity": "sha512-JIt98Fs0c8vhOw3uNf+sxqmvCdo0VoCZPBRg4frNK/xNpDMZsQh7V0Rp7wiGbr3f1w+4oqv40sfgaftMQTnwXQ==",
"license": "MIT",
"peer": true
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@primer/css": "^22.1.0",
"@tsparticles/slim": "^3.9.1",
"@tsparticles/vue3": "^3.0.1",
"axios": "^1.13.2",

135
src/api/download.ts Normal file
View File

@@ -0,0 +1,135 @@
import request from '@/utils/request'
/** 下载资源信息 */
export interface DownloadResource {
id?: string
created_at: string
created_by: string
download_url: string
features: string | null
file_hash: string | null
file_size: string | null
is_active: boolean | null
is_test?: boolean
package_type: string
version: string
}
/**
* 获取所有发布的资源
* GET /download-resources
*/
export function getDownloadResourcesApi(): Promise<DownloadResource[]> {
return request({
url: '/download-resources',
method: 'get',
})
}
/**
* 获取最新版本
* GET /download-resources/latest
*/
export function getLatestVersionApi(): Promise<DownloadResource> {
return request({
url: '/download-resources/latest',
method: 'get',
})
}
/**
* 获取测试版本
* GET /download-resources?is_test=true
*/
export function getTestVersionApi(): Promise<DownloadResource[]> {
return request({
url: '/download-resources',
method: 'get',
params: {
is_test: true
}
})
}
/**
* 获取资源列表(包含未激活的)
* GET /web-api/download-resources
* @param package_type 筛选包类型msi或者msix
* @param is_active 筛选是否激活
*/
export function getDownloadResourceListApi(params?: {
package_type?: string
is_active?: string
}): Promise<DownloadResource[]> {
return request({
url: '/web-api/download-resources',
method: 'get',
params,
})
}
/**
* 获取单个资源详情
* GET /web-api/download-resources/{resource_id}
* @param resource_id 资源id
*/
export function getDownloadResourceDetailApi(resource_id: string): Promise<DownloadResource> {
return request({
url: `/web-api/download-resources/${resource_id}`,
method: 'get',
})
}
/**
* 删除下载资源
* DELETE /web-api/download-resources/{resource_id}
* @param resource_id 资源id
*/
export function deleteDownloadResourceApi(resource_id: string): Promise<null> {
return request({
url: `/web-api/download-resources/${resource_id}`,
method: 'delete',
})
}
/** 创建资源请求参数类型 */
export interface CreateResourceRequest {
version: string
package_type: string
download_url: string
features?: string | null
file_size?: string | null
file_hash?: string | null
is_active?: boolean | null
is_test?: boolean
}
/** 创建资源响应数据类型 */
export interface CreateResourceResponse {
id: string
}
/**
* 创建新版本资源信息
* POST /web-api/download-resources
*/
export function createDownloadResourceApi(params: CreateResourceRequest): Promise<CreateResourceResponse> {
return request({
url: '/web-api/download-resources',
method: 'post',
data: params,
})
}
/**
* 更新下载资源
* PUT /web-api/download-resources/{resource_id}
* @param resource_id 资源id
*/
export function updateDownloadResourceApi(resource_id: string, params: Partial<CreateResourceRequest>): Promise<null> {
return request({
url: `/web-api/download-resources/${resource_id}`,
method: 'put',
data: params,
})
}

View File

@@ -44,14 +44,22 @@ export function getUserInfoApi(): Promise<UserInfo> {
/**
* 获取用户列表
* GET /web-api/users
* @param q 搜索参数可搜索用户名、邮箱、_id
* @param params 搜索和筛选参数
* @param params.q 搜索关键词可搜索用户名、邮箱、_id
* @param params.role 按角色筛选maintainer, developer, user
* @param params.email 按邮箱筛选
* @param params.username 按用户名筛选
* @param params.id 按用户ID筛选
* @param params.is 按状态筛选licensed, not-licensed
*/
export function getUserListApi(q?: string): Promise<UserListItem[]> {
const params: Record<string, any> = {}
if (q) {
params.q = q
}
export function getUserListApi(params?: {
q?: string
role?: string
email?: string
username?: string
id?: string
is?: string
}): Promise<UserListItem[]> {
return request({
url: '/web-api/users',
method: 'get',

View File

@@ -0,0 +1,637 @@
<template>
<div class="github-search-container gh-search-root" :class="{ 'dark-mode': isDarkMode }">
<div class="search-input-wrapper">
<!-- 语法高亮显示层 -->
<div class="syntax-highlight-layer" v-if="searchValue" v-html="highlightedText"></div>
<!-- 实际输入框 -->
<input
ref="searchInput"
v-model="searchValue"
type="text"
class="form-control"
:class="{ 'has-content': searchValue }"
:placeholder="placeholder"
@input="handleInput"
@keydown="handleKeydown"
@focus="showSuggestions = true"
@blur="handleBlur"
/>
<div class="search-icon">
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-search">
<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"></path>
</svg>
</div>
</div>
<!-- 自动补全建议下拉菜单 -->
<div v-if="showSuggestions && filteredSuggestions.length > 0" class="search-suggestions">
<div
v-for="(suggestion, index) in filteredSuggestions"
:key="index"
class="suggestion-item"
:class="{ active: selectedIndex === index }"
@mousedown.prevent="selectSuggestion(suggestion)"
>
<span class="suggestion-text">{{ suggestion.text }}</span>
<span class="suggestion-desc">{{ suggestion.description }}</span>
</div>
</div>
<!-- 搜索限定符帮助提示 -->
<div v-if="showSuggestions && currentQualifier && currentQualifierHelp" class="qualifier-help">
<div class="help-title">{{ currentQualifierHelp.title }}</div>
<div class="help-desc">{{ currentQualifierHelp.description }}</div>
<div class="help-examples">
<div v-for="(example, idx) in currentQualifierHelp.examples" :key="idx" class="example-item">
<code>{{ example }}</code>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
interface SearchQualifier {
key: string
title: string
description: string
examples: string[]
autocomplete?: string[]
}
interface SearchSuggestion {
text: string
description: string
type: 'qualifier' | 'value'
qualifier?: string
}
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'search': [query: string, filters: Record<string, string>]
}>()
const searchValue = ref(props.modelValue)
const searchInput = ref<HTMLInputElement>()
const showSuggestions = ref(false)
const selectedIndex = ref(0)
const isDarkMode = ref(false)
// 检测暗色模式
const checkDarkMode = () => {
isDarkMode.value = document.documentElement.classList.contains('dark')
}
onMounted(() => {
checkDarkMode()
// 监听主题变化
const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
onUnmounted(() => {
observer.disconnect()
})
})
// 搜索限定符定义
const qualifiers: SearchQualifier[] = [
{
key: 'role',
title: '角色',
description: '按用户角色筛选',
examples: ['role:maintainer', 'role:developer', 'role:user']
},
{
key: 'email',
title: '邮箱',
description: '按邮箱地址筛选',
examples: ['email:test@example.com', 'email:outlook.com']
},
{
key: 'username',
title: '用户名',
description: '按用户名筛选',
examples: ['username:testuser', 'username:admin']
},
{
key: 'id',
title: '用户ID',
description: '按用户ID筛选',
examples: ['id:123456789', 'id:507f1f77bcf86cd799439011']
}
// 这个应该没必要这个实际上就是筛选开发者权限和role:developer重复了
// {
// key: 'is',
// title: '状态',
// description: '按权限状态筛选',
// examples: ['is:licensed', 'is:not-licensed'],
// autocomplete: ['licensed', 'not-licensed']
// }
]
// 检查是否为已知的限定符
function isQualifier(text: string): boolean {
return qualifiers.some(q => q.key === text)
}
// 生成高亮的 HTML
const highlightedText = computed(() => {
if (!searchValue.value) return ''
let result = ''
const text = searchValue.value
// 按空格分割成段
const segments = text.split(' ')
segments.forEach((segment, index) => {
if (segment === '') {
// 空段表示有空格
result += '<span class="hl-space"> </span>'
} else {
// 检查是否包含冒号
const colonIndex = segment.indexOf(':')
if (colonIndex !== -1) {
// 有冒号,可能是限定符:值格式
const qualifier = segment.slice(0, colonIndex)
const value = segment.slice(colonIndex + 1)
if (isQualifier(qualifier)) {
// 已知限定符
result += `<span class="hl-qualifier">${escapeHtml(qualifier)}</span><span class="hl-colon">:</span><span class="hl-value">${escapeHtml(value)}</span>`
} else {
// 未知限定符,作为普通文本处理
result += `<span class="hl-unknown">${escapeHtml(qualifier)}</span><span class="hl-colon">:</span><span class="hl-value">${escapeHtml(value)}</span>`
}
} else {
// 没有冒号,作为普通文本
result += `<span class="hl-text">${escapeHtml(segment)}</span>`
}
}
// 添加段之间的空格(除了最后一段)
if (index < segments.length - 1) {
result += '<span class="hl-space"> </span>'
}
})
return result
})
// HTML 转义
function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// 当前输入的限定符(基于当前段)
const currentQualifier = computed(() => {
const lastSpaceIndex = searchValue.value.lastIndexOf(' ')
const afterLastSpace = searchValue.value.slice(lastSpaceIndex + 1)
const match = afterLastSpace.match(/^(\w+):/)
return match ? match[1] : null
})
// 当前限定符的帮助信息
const currentQualifierHelp = computed(() => {
if (!currentQualifier.value) return null
return qualifiers.find(q => q.key === currentQualifier.value) || null
})
// 建议列表
const suggestions = computed<SearchSuggestion[]>(() => {
const result: SearchSuggestion[] = []
// 检查是否正在输入新的限定符(最后一个空格后)
const lastSpaceIndex = searchValue.value.lastIndexOf(' ')
const afterLastSpace = searchValue.value.slice(lastSpaceIndex + 1)
const hasColonInCurrentSegment = afterLastSpace.includes(':')
// 如果当前段没有冒号,则显示限定符建议
if (!hasColonInCurrentSegment) {
const input = afterLastSpace.toLowerCase()
// 如果输入为空,显示所有限定符;否则显示匹配的限定符
const shouldShowAll = input === ''
qualifiers.forEach(q => {
if (shouldShowAll || q.key.startsWith(input)) {
result.push({
text: `${q.key}:`,
description: q.title,
type: 'qualifier'
})
}
})
}
// 如果当前段有冒号,则显示该限定符的值建议
if (hasColonInCurrentSegment && currentQualifier.value && currentQualifierHelp.value) {
const colonIndex = afterLastSpace.indexOf(':')
const value = afterLastSpace.slice(colonIndex + 1)
const input = value.toLowerCase()
const qualifier = currentQualifierHelp.value
if (qualifier.autocomplete) {
qualifier.autocomplete.forEach(val => {
if (val.startsWith(input)) {
result.push({
text: `${qualifier.key}:${val}`,
description: '',
type: 'value',
qualifier: qualifier.key
})
}
})
}
// 为特定限定符添加常用值建议
if (qualifier.key === 'role') {
const roles = ['maintainer', 'developer', 'user']
roles.forEach(role => {
if (role.startsWith(input)) {
result.push({
text: `role:${role}`,
description: role === 'maintainer' ? '运维人员' : role === 'developer' ? '开发者' : '普通用户',
type: 'value',
qualifier: 'role'
})
}
})
}
}
return result
})
// 过滤后的建议
const filteredSuggestions = computed(() => {
return suggestions.value
})
// 处理输入
function handleInput() {
emit('update:modelValue', searchValue.value)
selectedIndex.value = 0
// 如果有建议内容,自动显示建议框
if (suggestions.value.length > 0) {
showSuggestions.value = true
}
}
// 处理键盘事件
function handleKeydown(event: KeyboardEvent) {
if (!showSuggestions.value || filteredSuggestions.value.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredSuggestions.value.length - 1)
break
case 'ArrowUp':
event.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
break
case 'Enter':
const selectedSuggestion = filteredSuggestions.value[selectedIndex.value]
if (selectedSuggestion) {
selectSuggestion(selectedSuggestion)
} else {
handleSearch()
}
break
case 'Escape':
// ESC键关闭建议框
showSuggestions.value = false
break
}
}
// 选择建议
function selectSuggestion(suggestion: SearchSuggestion) {
if (suggestion.type === 'qualifier') {
// 如果是限定符,替换当前输入的限定符部分
const lastSpaceIndex = searchValue.value.lastIndexOf(' ')
const beforeLastSpace = searchValue.value.slice(0, lastSpaceIndex + 1)
searchValue.value = beforeLastSpace + suggestion.text
// 选择限定符后不关闭建议框,让用户继续输入值
} else {
// 如果是值,替换整个限定符+值,并添加空格以便输入下一个限定符
const lastSpaceIndex = searchValue.value.lastIndexOf(' ')
const beforeLastSpace = searchValue.value.slice(0, lastSpaceIndex + 1)
searchValue.value = beforeLastSpace + suggestion.text + ' '
// 选择值后重新打开建议框,方便继续输入下一个限定符
// 使用setTimeout确保DOM更新后再打开
setTimeout(() => {
showSuggestions.value = true
}, 0)
}
emit('update:modelValue', searchValue.value)
nextTick(() => {
searchInput.value?.focus()
})
}
// 处理失去焦点
function handleBlur() {
// 延迟隐藏,以便能够点击建议项
setTimeout(() => {
showSuggestions.value = false
}, 200)
}
// 解析搜索查询
function parseSearchQuery(query: string): { keyword: string; filters: Record<string, string> } {
const filters: Record<string, string> = {}
const parts = query.split(/\s+/)
const keywords: string[] = []
parts.forEach(part => {
if (part.includes(':')) {
const [key, value] = part.split(':')
if (key && value) {
filters[key] = value
}
} else if (part.trim()) {
keywords.push(part)
}
})
return {
keyword: keywords.join(' '),
filters
}
}
// 执行搜索
function handleSearch() {
const { filters } = parseSearchQuery(searchValue.value)
emit('search', searchValue.value, filters)
showSuggestions.value = false
}
// 监听modelValue变化
watch(() => props.modelValue, (newValue) => {
searchValue.value = newValue
})
// 暴露搜索方法
defineExpose({
handleSearch
})
</script>
<style>
/* 定义全局CSS变量 - 亮色模式 */
.gh-search-root {
--color-fg-default: #24292f;
--color-fg-muted: #57606a;
--color-accent-fg: #0969da;
--color-accent-muted: rgba(9, 105, 218, 0.1);
--color-accent-subtle: #ddf4ff;
--color-bg-subtle: #f6f8fa;
--color-bg-canvas: #ffffff;
--color-canvas-overlay: #ffffff;
--color-border-default: #d0d7de;
--color-shadow-medium: 0 3px 6px rgba(140, 149, 159, 0.15);
/* 高亮颜色 - 亮色模式 */
--hl-qualifier: #cf222e;
--hl-colon: #cf222e;
--hl-value: #0550ae;
--hl-text: #24292f;
--hl-unknown: #6e7781;
}
/* 定义全局CSS变量 - 暗色模式 */
.gh-search-root.dark-mode {
--color-fg-default: #c9d1d9;
--color-fg-muted: #8b949e;
--color-accent-fg: #58a6ff;
--color-accent-muted: rgba(88, 166, 255, 0.15);
--color-accent-subtle: rgba(88, 166, 255, 0.15);
--color-bg-subtle: #161b22;
--color-bg-canvas: #0d1117;
--color-canvas-overlay: #161b22;
--color-border-default: #30363d;
--color-shadow-medium: 0 3px 6px rgba(0, 0, 0, 0.5);
/* 高亮颜色 - 暗色模式 */
--hl-qualifier: #ff7b72;
--hl-colon: #ff7b72;
--hl-value: #79c0ff;
--hl-text: #c9d1d9;
--hl-unknown: #8b949e;
}
/* 语法高亮样式 - 非scoped用于v-html生成的HTML */
.gh-search-root .hl-qualifier {
color: var(--hl-qualifier);
font-weight: 500;
}
.gh-search-root .hl-colon {
color: var(--hl-colon);
font-weight: 500;
}
.gh-search-root .hl-value {
color: var(--hl-value);
}
.gh-search-root .hl-text {
color: var(--hl-text);
}
.gh-search-root .hl-unknown {
color: var(--hl-unknown);
}
.gh-search-root .hl-space {
color: var(--color-fg-muted);
}
</style>
<style scoped>
.github-search-container {
position: relative;
width: 100%;
}
.search-input-wrapper {
position: relative;
width: 100%;
background-color: var(--color-bg-subtle);
border-radius: 6px;
box-sizing: border-box;
}
/* 语法高亮显示层 */
.syntax-highlight-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px 36px 8px 12px;
font-size: 14px;
line-height: 20px;
font-family: inherit;
font-weight: normal;
white-space: pre;
overflow: hidden;
pointer-events: none;
z-index: 1;
color: var(--color-fg-default);
background: transparent;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.form-control {
width: 100%;
padding: 8px 36px 8px 12px;
font-size: 14px;
line-height: 20px;
font-family: inherit;
font-weight: normal;
color: var(--color-fg-default);
caret-color: var(--color-fg-default);
background: transparent;
border: 1px solid var(--color-border-default);
border-radius: 6px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
position: relative;
z-index: 2;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.form-control.has-content {
color: transparent;
-webkit-text-fill-color: transparent;
}
.form-control:focus {
outline: none;
}
.form-control:focus + .search-icon {
color: var(--color-accent-fg);
}
.search-input-wrapper:focus-within {
border-color: var(--color-accent-fg);
box-shadow: 0 0 0 3px var(--color-accent-muted);
background-color: var(--color-bg-canvas);
}
.form-control::placeholder {
color: var(--color-fg-muted);
}
.search-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--color-fg-muted);
pointer-events: none;
}
.search-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 4px;
background-color: var(--color-canvas-overlay);
border: 1px solid var(--color-border-default);
border-radius: 6px;
box-shadow: var(--color-shadow-medium);
max-height: 300px;
overflow-y: auto;
}
.suggestion-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: background-color 0.15s ease-in-out;
}
.suggestion-item:hover,
.suggestion-item.active {
background-color: var(--color-accent-subtle);
}
.suggestion-text {
font-size: 14px;
font-weight: 600;
color: var(--color-accent-fg);
}
.suggestion-desc {
font-size: 12px;
color: var(--color-fg-muted);
}
.qualifier-help {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 999;
margin-top: 4px;
padding: 12px;
background-color: var(--color-canvas-overlay);
border: 1px solid var(--color-border-default);
border-radius: 6px;
box-shadow: var(--color-shadow-medium);
}
.help-title {
font-size: 14px;
font-weight: 600;
color: var(--color-fg-default);
margin-bottom: 4px;
}
.help-desc {
font-size: 12px;
color: var(--color-fg-muted);
margin-bottom: 8px;
}
.help-examples {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.example-item {
code {
display: inline-block;
padding: 2px 6px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
color: var(--color-accent-fg);
background-color: var(--color-accent-subtle);
border-radius: 3px;
}
}
</style>

View File

@@ -12,6 +12,11 @@ const routes = [
component: () => import('@/views/home/index.vue'),
meta: { hidden: true }
},
{
path: '/download',
component: () => import('@/views/download/index.vue'),
meta: { hidden: true }
},
{
path: '/dashboard',
component: DefaultLayout,
@@ -47,6 +52,11 @@ const routes = [
component: () => import('@/views/announcement/index.vue'),
meta: { title: '公告管理', icon: 'Bell' },
},
{
path: 'download-manager',
component: () => import('@/views/download-manager/index.vue'),
meta: { title: '下载资源管理', icon: 'Download' },
},
],
},
],

View File

@@ -6,8 +6,8 @@ router.beforeEach(async (to, _ , next) => {
// 未登录
if (!userStore.token) {
// 主页(/)允许未登录访问
if (to.path === '/' || to.path === '/login') {
// 主页(/、登录页和下载页允许未登录访问
if (to.path === '/' || to.path === '/login' || to.path === '/download') {
next()
} else {
next('/login')

View File

@@ -2,13 +2,13 @@ import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const request = axios.create({
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
/** 请求拦截:自动加 Token */
request.interceptors.request.use((config) => {
axiosInstance.interceptors.request.use((config: any) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers = config.headers || {}
@@ -18,8 +18,8 @@ request.interceptors.request.use((config) => {
})
/** 响应拦截:兼容 code / retcode */
request.interceptors.response.use(
(response) => {
axiosInstance.interceptors.response.use(
(response: any) => {
const res = response.data
// 登录接口code
@@ -43,24 +43,24 @@ request.interceptors.response.use(
// 兜底
return res
},
(error) => {
(error: any) => {
// 处理401未授权错误
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
// 如果不在登录页,则跳转到登录页
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
ElMessage.error('登录已过期,请重新登录')
return Promise.reject(new Error('登录已过期'))
}
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default request
export default axiosInstance

View File

@@ -0,0 +1,661 @@
<template>
<div class="resource-management">
<!-- 搜索栏和统计 -->
<div class="search-statistics-row">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="包类型">
<el-select
v-model="searchForm.package_type"
placeholder="全部"
clearable
@change="handleSearch"
>
<el-option label="MSI" value="msi" />
<el-option label="MSIX" value="msix" />
</el-select>
</el-form-item>
<el-form-item label="激活状态">
<el-select
v-model="searchForm.is_active"
placeholder="全部"
clearable
@change="handleSearch"
>
<el-option label="已激活" value="true" />
<el-option label="未激活" value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :loading="loading">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 统计信息 -->
<div class="statistics" v-if="!loading && resourceList.length > 0">
<el-statistic title="资源总数" :value="resourceList.length" />
<el-statistic title="MSI包" :value="msiCount" />
<el-statistic title="MSIX包" :value="msixCount" />
<el-statistic title="已激活" :value="activeCount" />
</div>
</div>
<!-- 操作按钮 -->
<div class="toolbar">
<el-button type="success" @click="handleCreate">创建资源</el-button>
<el-button type="primary" @click="handleRefresh" :loading="loading">刷新</el-button>
</div>
<!-- 资源表格 -->
<el-table
:data="resourceList"
style="width: 100%"
border
v-loading="loading"
element-loading-text="正在加载资源数据..."
>
<el-table-column prop="version" label="版本号" width="120" />
<el-table-column label="包类型" width="100">
<template #default="scope">
<el-tag :type="scope.row.package_type === 'msix' ? 'success' : 'primary'" size="small">
{{ scope.row.package_type.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="激活状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.is_active ? 'success' : 'info'" size="small">
{{ scope.row.is_active ? '已激活' : '未激活' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="文件大小" width="120" />
<el-table-column label="下载链接" min-width="200">
<template #default="scope">
<el-link
:href="scope.row.download_url"
target="_blank"
type="primary"
:underline="false"
>
{{ scope.row.download_url }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="features" label="新功能描述" min-width="250" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ formatTime(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
link
@click="handleView(scope.row)"
>
详情
</el-button>
<el-button
size="small"
type="warning"
link
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
link
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty v-if="!loading && resourceList.length === 0" description="暂无资源数据" />
<!-- 资源详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="资源详情"
width="60%"
>
<div v-if="currentResource" class="resource-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="版本号">
<el-tag type="primary">{{ currentResource.version }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="包类型">
<el-tag :type="currentResource.package_type === 'msix' ? 'success' : 'primary'">
{{ currentResource.package_type.toUpperCase() }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="激活状态">
<el-tag :type="currentResource.is_active ? 'success' : 'info'">
{{ currentResource.is_active ? '已激活' : '未激活' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件大小">
{{ currentResource.file_size || '-' }}
</el-descriptions-item>
<el-descriptions-item label="文件哈希" :span="2">
{{ currentResource.file_hash || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建者ID">
{{ currentResource.created_by }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatTime(currentResource.created_at) }}
</el-descriptions-item>
<el-descriptions-item label="下载链接" :span="2">
<el-link
:href="currentResource.download_url"
target="_blank"
type="primary"
>
{{ currentResource.download_url }}
</el-link>
</el-descriptions-item>
<el-descriptions-item label="新功能描述" :span="2">
<div class="features-content">
{{ currentResource.features || '无' }}
</div>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
<!-- 创建资源弹窗 -->
<el-dialog
v-model="createDialogVisible"
title="创建资源"
width="60%"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-width="120px"
>
<el-form-item label="版本号" prop="version">
<el-input
v-model="createForm.version"
placeholder="请输入版本号1.0.0"
/>
</el-form-item>
<el-form-item label="包类型" prop="package_type">
<el-select
v-model="createForm.package_type"
placeholder="请选择包类型"
style="width: 100%"
>
<el-option label="MSI" value="msi" />
<el-option label="MSIX" value="msix" />
</el-select>
</el-form-item>
<el-form-item label="下载链接" prop="download_url">
<el-input
v-model="createForm.download_url"
placeholder="请输入下载URL"
/>
</el-form-item>
<el-form-item label="文件大小">
<el-input
v-model="createForm.file_size"
placeholder="可选114514KB"
/>
</el-form-item>
<el-form-item label="文件哈希">
<el-input
v-model="createForm.file_hash"
placeholder="可选,文件哈希值"
/>
</el-form-item>
<el-form-item label="激活状态">
<el-switch
v-model="createForm.is_active"
active-text="已激活"
inactive-text="未激活"
/>
</el-form-item>
<el-form-item label="测试版">
<el-switch
v-model="createForm.is_test"
active-text=""
inactive-text=""
/>
</el-form-item>
<el-form-item label="新功能描述">
<el-input
v-model="createForm.features"
type="textarea"
:rows="4"
placeholder="可选,描述新功能内容"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="createLoading"
@click="handleCreateSubmit"
>
创建
</el-button>
</div>
</template>
</el-dialog>
<!-- 编辑资源弹窗 -->
<el-dialog
v-model="editDialogVisible"
title="编辑资源"
width="60%"
>
<el-form
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-width="120px"
>
<el-form-item label="版本号" prop="version">
<el-input
v-model="editForm.version"
placeholder="请输入版本号1.0.0"
/>
</el-form-item>
<el-form-item label="包类型" prop="package_type">
<el-select
v-model="editForm.package_type"
placeholder="请选择包类型"
style="width: 100%"
>
<el-option label="MSI" value="msi" />
<el-option label="MSIX" value="msix" />
</el-select>
</el-form-item>
<el-form-item label="下载链接" prop="download_url">
<el-input
v-model="editForm.download_url"
placeholder="请输入下载URL"
/>
</el-form-item>
<el-form-item label="文件大小">
<el-input
v-model="editForm.file_size"
placeholder="可选114514KB"
/>
</el-form-item>
<el-form-item label="文件哈希">
<el-input
v-model="editForm.file_hash"
placeholder="可选,文件哈希值"
/>
</el-form-item>
<el-form-item label="激活状态">
<el-switch
v-model="editForm.is_active"
active-text="已激活"
inactive-text="未激活"
/>
</el-form-item>
<el-form-item label="测试版">
<el-switch
v-model="editForm.is_test"
active-text=""
inactive-text=""
/>
</el-form-item>
<el-form-item label="新功能描述">
<el-input
v-model="editForm.features"
type="textarea"
:rows="4"
placeholder="可选,描述新功能内容"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="editLoading"
@click="handleEditSubmit"
>
保存
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getDownloadResourceListApi,
deleteDownloadResourceApi,
createDownloadResourceApi,
updateDownloadResourceApi,
type DownloadResource,
type CreateResourceRequest,
} from '@/api/download'
interface SearchForm {
package_type: string
is_active: string
}
const searchForm = reactive<SearchForm>({
package_type: '',
is_active: '',
})
const resourceList = ref<DownloadResource[]>([])
const loading = ref(false)
const detailDialogVisible = ref(false)
const currentResource = ref<DownloadResource | null>(null)
// 创建资源相关
const createDialogVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref<FormInstance>()
const createForm = reactive<CreateResourceRequest>({
version: '',
package_type: 'msix',
download_url: '',
features: '',
file_size: '',
file_hash: '',
is_active: true,
is_test: false,
})
const createRules: FormRules = {
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' },
{ pattern: /^\d+\.\d+\.\d+$/, message: '版本号格式应为 x.y.z如 1.0.0', trigger: 'blur' },
],
package_type: [
{ required: true, message: '请选择包类型', trigger: 'change' },
],
download_url: [
{ required: true, message: '请输入下载链接', trigger: 'blur' },
{ type: 'url', message: '请输入有效的URL地址', trigger: 'blur' },
],
}
// 编辑资源相关
const editDialogVisible = ref(false)
const editLoading = ref(false)
const editFormRef = ref<FormInstance>()
const currentEditId = ref<string | null>(null)
const editForm = reactive<CreateResourceRequest>({
version: '',
package_type: 'msix',
download_url: '',
features: '',
file_size: '',
file_hash: '',
is_active: true,
is_test: false,
})
const editRules: FormRules = {
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' },
{ pattern: /^\d+\.\d+\.\d+$/, message: '版本号格式应为 x.y.z如 1.0.0', trigger: 'blur' },
],
package_type: [
{ required: true, message: '请选择包类型', trigger: 'change' },
],
download_url: [
{ required: true, message: '请输入下载链接', trigger: 'blur' },
{ type: 'url', message: '请输入有效的URL地址', trigger: 'blur' },
],
}
// 统计数据
const msiCount = computed(() =>
resourceList.value.filter(item => item.package_type === 'msi').length
)
const msixCount = computed(() =>
resourceList.value.filter(item => item.package_type === 'msix').length
)
const activeCount = computed(() =>
resourceList.value.filter(item => item.is_active === true).length
)
// 格式化时间
function formatTime(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 获取资源列表
async function fetchResourceList() {
loading.value = true
try {
const params: Record<string, any> = {}
if (searchForm.package_type) {
params.package_type = searchForm.package_type
}
if (searchForm.is_active) {
params.is_active = searchForm.is_active
}
const data = await getDownloadResourceListApi(Object.keys(params).length > 0 ? params : undefined)
resourceList.value = data || []
} catch (error) {
console.error('获取资源列表失败:', error)
ElMessage.error('获取资源列表失败')
resourceList.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
fetchResourceList()
}
function handleReset() {
searchForm.package_type = ''
searchForm.is_active = ''
fetchResourceList()
}
function handleRefresh() {
fetchResourceList()
}
function handleCreate() {
// 重置表单
Object.assign(createForm, {
version: '',
package_type: 'msix',
download_url: '',
features: '',
file_size: '',
file_hash: '',
is_active: true,
is_test: false,
})
createDialogVisible.value = true
}
async function handleCreateSubmit() {
if (!createFormRef.value) return
try {
await createFormRef.value.validate()
createLoading.value = true
const result = await createDownloadResourceApi(createForm)
if (result && result.id) {
ElMessage.success('资源创建成功')
createDialogVisible.value = false
// 刷新列表
await fetchResourceList()
} else {
ElMessage.error('创建资源失败')
}
} catch (error) {
ElMessage.error('创建资源失败')
} finally {
createLoading.value = false
}
}
function handleEdit(resource: DownloadResource) {
if (!resource.id) {
ElMessage.error('资源ID不存在无法编辑')
return
}
currentEditId.value = resource.id
// 填充表单数据
Object.assign(editForm, {
version: resource.version,
package_type: resource.package_type,
download_url: resource.download_url,
features: resource.features || '',
file_size: resource.file_size || '',
file_hash: resource.file_hash || '',
is_active: resource.is_active ?? true,
is_test: resource.is_test ?? false,
})
editDialogVisible.value = true
}
async function handleEditSubmit() {
if (!editFormRef.value || !currentEditId.value) return
try {
await editFormRef.value.validate()
editLoading.value = true
await updateDownloadResourceApi(currentEditId.value, editForm)
ElMessage.success('资源更新成功')
editDialogVisible.value = false
// 刷新列表
await fetchResourceList()
} catch (error) {
ElMessage.error('更新资源失败')
} finally {
editLoading.value = false
}
}
function handleView(resource: DownloadResource) {
currentResource.value = resource
detailDialogVisible.value = true
}
async function handleDelete(resource: DownloadResource) {
try {
await ElMessageBox.confirm(
`确定要删除版本 ${resource.version}${resource.package_type.toUpperCase()} 包吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
if (!resource.id) {
ElMessage.error('资源ID不存在无法删除')
return
}
await deleteDownloadResourceApi(resource.id)
ElMessage.success('资源删除成功')
await fetchResourceList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除资源失败')
}
}
}
onMounted(() => {
fetchResourceList()
})
</script>
<style scoped>
.resource-management {
padding: 24px;
}
.search-statistics-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.search-form {
margin-bottom: 0;
flex: 1;
}
.statistics {
margin-left: 32px;
display: flex;
gap: 24px;
margin-top: 0;
}
.toolbar {
margin-bottom: 16px;
}
.features-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
</style>

View File

@@ -0,0 +1,730 @@
<template>
<div class="download-page">
<Header
:app-icon="appIcon"
:app-name="appName"
:actions="headerActions"
/>
<div class="download-content">
<div class="download-section">
<h1 class="section-title">
<el-icon><Download /></el-icon>
下载中心
</h1>
<p class="section-description">选择适合您的安装包立即开始使用 Snap Hutao<br>系统要求新版本Windows10及Windows11</p>
<!-- 版本类型切换 -->
<div class="version-tabs">
<el-tabs v-model="versionType" @tab-change="switchVersionType">
<el-tab-pane label="正式版" name="stable">
<template #label>
<span class="tab-label">
<el-icon><Document /></el-icon>
正式版
</span>
</template>
</el-tab-pane>
<el-tab-pane label="测试版" name="test">
<template #label>
<span class="tab-label">
<el-icon><Box /></el-icon>
测试版
</span>
</template>
</el-tab-pane>
</el-tabs>
<!-- 测试版警告提示 -->
<el-alert
v-if="versionType === 'test'"
title="测试版说明"
type="warning"
:closable="false"
show-icon
>
<p>测试版可能存在未知的 Bug 和稳定性问题仅供测试和体验新功能使用如需稳定使用请下载正式版</p>
</el-alert>
</div>
<!-- 最新版本下载区域 -->
<div v-if="latestVersion" class="latest-version-card">
<div class="latest-header">
<el-tag
:type="versionType === 'test' ? 'warning' : 'success'"
size="large"
effect="dark"
>
{{ versionType === 'test' ? '最新测试版本' : '最新版本' }}
</el-tag>
<h2 class="version-title">{{ latestVersion.version }}</h2>
</div>
<div v-if="latestVersion.features" class="features-section">
<h3 class="features-title">更新内容</h3>
<p class="features-text">{{ latestVersion.features }}</p>
</div>
<div class="download-buttons">
<el-tooltip
v-if="latestVersion.packages.msi"
effect="dark"
placement="top"
:content="getTooltipText(latestVersion, 'msi')"
>
<el-button
type="primary"
size="large"
@click="downloadFile(latestVersion, 'msi')"
:loading="downloading"
>
<el-icon><Document /></el-icon>
<span>下载 MSI 安装包</span>
</el-button>
</el-tooltip>
<el-tooltip
v-if="latestVersion.packages.msix"
effect="dark"
placement="top"
:content="getTooltipText(latestVersion, 'msix')"
>
<el-button
type="success"
size="large"
@click="downloadFile(latestVersion, 'msix')"
:loading="downloading"
>
<el-icon><Box /></el-icon>
<span>下载 MSIX 安装包</span>
</el-button>
</el-tooltip>
</div>
<div class="version-info">
<span class="info-item">
<el-icon><Clock /></el-icon>
发布时间{{ formatDate(latestVersion.created_at) }}
</span>
</div>
</div>
<!-- 历史版本列表仅正式版显示 -->
<el-divider v-if="versionType === 'stable'">历史版本</el-divider>
<div v-loading="loading" class="history-list">
<!-- 正式版显示历史版本空状态 -->
<div v-if="versionType === 'stable' && historyVersions.length === 0 && !loading" class="empty-state">
<el-icon><FolderOpened /></el-icon>
<p>暂无历史版本</p>
</div>
<!-- 正式版显示历史版本列表 -->
<div v-else-if="versionType === 'stable' && historyVersions.length > 0" class="version-table">
<div
v-for="(item, index) in historyVersions"
:key="index"
class="version-item"
>
<div class="version-main">
<div class="version-number">{{ item.version }}</div>
<div class="version-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
{{ formatDate(item.created_at) }}
</span>
</div>
<div v-if="item.features" class="version-features">
{{ item.features }}
</div>
</div>
<div class="version-actions">
<el-tooltip
v-if="item.packages.msi"
effect="dark"
placement="top"
:content="getTooltipText(item, 'msi')"
>
<el-button
type="primary"
size="small"
@click="downloadFile(item, 'msi')"
>
MSI
</el-button>
</el-tooltip>
<el-tooltip
v-if="item.packages.msix"
effect="dark"
placement="top"
:content="getTooltipText(item, 'msix')"
>
<el-button
type="success"
size="small"
@click="downloadFile(item, 'msix')"
>
MSIX
</el-button>
</el-tooltip>
</div>
</div>
</div>
<!-- 测试版显示空状态 -->
<div v-if="versionType === 'test' && !latestVersion && !loading" class="empty-state">
<el-icon><FolderOpened /></el-icon>
<p>暂无测试版本</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Download,
Document,
Box,
Clock,
FolderOpened,
} from '@element-plus/icons-vue'
import Header from '@/components/Header.vue'
import { getDownloadResourcesApi, getTestVersionApi } from '@/api/download'
const router = useRouter()
// 配置项
const appIcon = ref('/HT_logo.png')
const appName = ref('Snap Hutao')
// 页头右侧按钮配置
const headerActions = ref([
{
id: 'home',
label: '返回首页',
icon: undefined,
component: 'el-button',
props: {
type: 'default',
link: true
},
onClick: () => {
router.push('/')
}
}
])
// 版本信息接口(合并不同包类型)
interface VersionInfo {
version: string
created_at: string
created_by: string
features: string | null
file_hash: string | null
file_size: string | null
is_active: boolean | null
packages: {
msi: string | null
msix: string | null
msi_size: string | null
msix_size: string | null
}
}
// 包类型描述
const packageDescriptions = {
msi: '传统安装包方便部署但是可能有BUG。',
msix: '现代化安装包稳定性更好原生体验推荐使用安装稍繁琐解压以后右键Add-AppDevPackage.ps1选择“以PowerShell运行”进行安装。',
}
// 数据状态
const latestVersion = ref<VersionInfo | null>(null)
const historyVersions = ref<VersionInfo[]>([])
const loading = ref(false)
const downloading = ref(false)
const versionType = ref<'stable' | 'test'>('stable')
/**
* 格式化日期
*/
function formatDate(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
/**
* 下载文件
*/
function downloadFile(item: VersionInfo, packageType: 'msi' | 'msix') {
const downloadUrl = item.packages[packageType]
if (!downloadUrl) {
ElMessage.warning(`${item.version} 版本暂无 ${packageType.toUpperCase()} 安装包`)
return
}
downloading.value = true
const a = document.createElement('a')
a.href = downloadUrl
// 如果是msix的话文件是用zip格式压缩的
if (packageType === 'msix') {
a.download = `Snap.Hutao.${item.version}.zip`
} else {
a.download = `Snap.Hutao.${item.version}.msi`
}
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
ElMessage.success(`开始下载 ${item.version} 版本的 ${packageType.toUpperCase()} 安装包`)
setTimeout(() => {
downloading.value = false
}, 1000)
}
/**
* 获取下载按钮的提示内容
*/
function getTooltipText(item: VersionInfo, packageType: 'msi' | 'msix') {
const size = item.packages[`${packageType}_size` as keyof typeof item.packages] as string | null
const desc = packageDescriptions[packageType]
if (size) {
return `${desc}\n文件大小${size}`
}
return desc
}
/**
* 加载正式版本
*/
async function loadAllVersions() {
try {
loading.value = true
const data = await getDownloadResourcesApi()
if (!data || data.length === 0) {
return
}
// 按版本号分组
const versionMap = new Map<string, VersionInfo>()
data.forEach((item) => {
if (!versionMap.has(item.version)) {
// 首次遇到该版本,创建新记录
versionMap.set(item.version, {
version: item.version,
created_at: item.created_at,
created_by: item.created_by,
features: item.features,
file_hash: item.file_hash,
file_size: item.file_size,
is_active: item.is_active,
packages: {
msi: null,
msix: null,
msi_size: null,
msix_size: null,
},
})
}
// 添加包类型的下载链接和大小
const versionInfo = versionMap.get(item.version)
if (versionInfo) {
if (item.package_type === 'msi') {
versionInfo.packages.msi = item.download_url
versionInfo.packages.msi_size = item.file_size
} else if (item.package_type === 'msix') {
versionInfo.packages.msix = item.download_url
versionInfo.packages.msix_size = item.file_size
}
}
})
// 转换为数组并按创建时间倒序排序
const versions = Array.from(versionMap.values()).sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
if (versions.length > 0) {
latestVersion.value = versions[0] ?? null
historyVersions.value = versions.slice(1)
}
} catch (error) {
console.error('加载版本列表失败:', error)
ElMessage.error('加载版本列表失败')
} finally {
loading.value = false
}
}
/**
* 加载测试版本
*/
async function loadTestVersion() {
try {
loading.value = true
const data = await getTestVersionApi()
if (!data || data.length === 0) {
latestVersion.value = null
historyVersions.value = []
return
}
// 按版本号分组
const versionMap = new Map<string, VersionInfo>()
data.forEach((item) => {
if (!versionMap.has(item.version)) {
// 首次遇到该版本,创建新记录
versionMap.set(item.version, {
version: item.version,
created_at: item.created_at,
created_by: item.created_by,
features: item.features,
file_hash: item.file_hash,
file_size: item.file_size,
is_active: item.is_active,
packages: {
msi: null,
msix: null,
msi_size: null,
msix_size: null,
},
})
}
// 添加包类型的下载链接和大小
const versionInfo = versionMap.get(item.version)
if (versionInfo) {
if (item.package_type === 'msi') {
versionInfo.packages.msi = item.download_url
versionInfo.packages.msi_size = item.file_size
} else if (item.package_type === 'msix') {
versionInfo.packages.msix = item.download_url
versionInfo.packages.msix_size = item.file_size
}
}
})
// 转换为数组并按创建时间倒序排序
const versions = Array.from(versionMap.values()).sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
if (versions.length > 0) {
latestVersion.value = versions[0] ?? null
// 测试版本只显示最新版本,不显示历史版本
historyVersions.value = []
} else {
latestVersion.value = null
historyVersions.value = []
}
} catch (error) {
console.error('加载测试版本失败:', error)
ElMessage.error('加载测试版本失败')
} finally {
loading.value = false
}
}
/**
* 切换版本类型
*/
function switchVersionType(type: 'stable' | 'test') {
versionType.value = type
latestVersion.value = null
historyVersions.value = []
if (type === 'stable') {
loadAllVersions()
} else {
loadTestVersion()
}
}
onMounted(() => {
loadAllVersions()
})
</script>
<style scoped>
.download-page {
min-height: 100vh;
background: var(--main-bg);
display: flex;
flex-direction: column;
}
.download-content {
flex: 1;
padding: 40px 20px;
}
.download-section {
max-width: 1000px;
margin: 0 auto;
}
.section-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 36px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-color);
background: linear-gradient(135deg, var(--aside-active), #67c23a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.section-description {
font-size: 16px;
color: var(--text-color);
opacity: 0.8;
margin-bottom: 20px;
}
/* 版本类型切换 */
.version-tabs {
margin-bottom: 32px;
}
.version-tabs :deep(.el-tabs__header) {
margin: 0;
}
.version-tabs :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.version-tabs :deep(.el-tabs__item) {
font-size: 16px;
padding: 0 24px;
}
.version-tabs .tab-label {
display: flex;
align-items: center;
gap: 6px;
}
.version-tabs :deep(.el-alert) {
margin-top: 16px;
padding: 12px 16px;
}
.version-tabs :deep(.el-alert__description) {
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
/* 最新版本卡片 */
.latest-version-card {
background: var(--card-bg);
border-radius: 16px;
padding: 32px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
margin-bottom: 40px;
}
.latest-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.version-title {
font-size: 32px;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.features-section {
margin-bottom: 24px;
}
.features-title {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 12px;
}
.features-text {
font-size: 14px;
color: var(--text-color);
opacity: 0.8;
line-height: 1.6;
white-space: pre-wrap;
background: var(--main-bg);
padding: 16px;
border-radius: 8px;
}
.download-buttons {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.download-buttons .el-button {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 32px;
font-size: 16px;
border-radius: 8px;
min-width: 200px;
justify-content: center;
}
.version-info {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-color);
opacity: 0.7;
}
/* 历史版本列表 */
.history-list {
min-height: 200px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-color);
opacity: 0.5;
}
.empty-state .el-icon {
font-size: 64px;
margin-bottom: 16px;
}
.version-table {
display: flex;
flex-direction: column;
gap: 16px;
}
.version-item {
background: var(--card-bg);
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
transition: all 0.3s ease;
}
.version-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.version-main {
flex: 1;
}
.version-number {
font-size: 20px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 8px;
}
.version-meta {
display: flex;
gap: 16px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: var(--text-color);
opacity: 0.7;
}
.version-features {
font-size: 14px;
color: var(--text-color);
opacity: 0.8;
line-height: 1.6;
white-space: pre-wrap;
background: var(--main-bg);
padding: 12px;
border-radius: 6px;
max-height: 80px;
overflow-y: auto;
}
.version-actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
/* 响应式 */
@media (max-width: 768px) {
.section-title {
font-size: 28px;
}
.latest-version-card {
padding: 24px;
}
.download-buttons {
flex-direction: column;
}
.download-buttons .el-button {
width: 100%;
}
.version-item {
flex-direction: column;
align-items: flex-start;
}
.version-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@@ -90,18 +90,28 @@ const headerActions = ref([
// 主页中心按钮配置
const heroButtons = ref([
// {
// id: 'btn1',
// label: '快速开始',
// type: 'primary',
// size: 'large',
// icon: undefined,
// onClick: () => {
// window.open('https://github.com/wangdage12/Snap.Hutao', '_blank')
// }
// },
{
id: 'btn1',
label: '快速开始',
id: 'btn2',
label: '立即下载',
type: 'primary',
size: 'large',
icon: undefined,
onClick: () => {
window.open('https://github.com/wangdage12/Snap.Hutao', '_blank')
router.push('/download')
}
},
{
id: 'btn2',
id: 'btn3',
label: '查看文档',
type: 'default',
size: 'large',

View File

@@ -94,7 +94,7 @@ const handleLogin = async () => {
const userStore = useUserStore()
// 处理响应数据结构
const tokenData = response.data || response
const tokenData = response as any
userStore.setToken(tokenData.access_token)
// 获取用户信息

View File

@@ -2,28 +2,27 @@
<div class="user-management">
<!-- 搜索栏和用户统计并排 -->
<div class="search-statistics-row">
<!-- 搜索 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="请输入用户名、邮箱或ID"
clearable
@keyup.enter="handleSearch"
<!-- GitHub样式搜索 -->
<div class="search-container">
<GitHubSearchInput
ref="githubSearch"
v-model="searchQuery"
placeholder="搜索用户... 例如: role:developer username:test"
@search="handleGitHubSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :loading="loading">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 用户统计 -->
<div class="statistics" v-if="!loading && displayUserList.length > 0">
<el-statistic title="当前显示用户数" :value="displayUserList.length" />
<el-statistic title="运维人员" :value="maintainerCount" />
<el-statistic title="开发者" :value="developerCount" />
<el-statistic v-if="isSearchMode" title="搜索模式" value="进行中" />
</div>
<div class="search-actions">
<el-button type="primary" @click="executeSearch" :loading="loading">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
</div>
<!-- 用户统计 -->
<div class="statistics" v-if="!loading && displayUserList.length > 0">
<el-statistic title="当前显示用户数" :value="displayUserList.length" />
<el-statistic title="运维人员" :value="maintainerCount" />
<el-statistic title="开发者" :value="developerCount" />
<el-statistic v-if="isSearchMode" title="搜索模式" value="进行中" />
</div>
</div>
<!-- 操作按钮 -->
@@ -67,45 +66,41 @@
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import GitHubSearchInput from '@/components/GitHubSearchInput.vue'
import { getUserListApi, type UserListItem } from '@/api/user'
interface SearchForm {
keyword: string
}
const searchForm = reactive<SearchForm>({
keyword: ''
})
const githubSearch = ref<InstanceType<typeof GitHubSearchInput>>()
const searchQuery = ref('')
const currentFilters = ref<Record<string, string>>({})
const userList = ref<UserListItem[]>([])
const loading = ref(false)
const isSearchMode = ref(false)
// 显示的用户列表(根据是否在搜索模式决定显示全部还是搜索结果
// 显示的用户列表(直接使用后端返回的数据
const displayUserList = computed(() => {
return userList.value
})
// 统计数据
const maintainerCount = computed(() =>
userList.value.filter(user => user.IsMaintainer).length
// 统计数据(基于当前显示的列表)
const maintainerCount = computed(() =>
displayUserList.value.filter(user => user.IsMaintainer).length
)
const developerCount = computed(() =>
userList.value.filter(user => user.IsLicensedDeveloper).length
const developerCount = computed(() =>
displayUserList.value.filter(user => user.IsLicensedDeveloper).length
)
// 获取用户列表
async function fetchUserList(searchKeyword?: string) {
async function fetchUserList(filters?: Record<string, string>) {
loading.value = true
try {
const data = await getUserListApi(searchKeyword)
const data = await getUserListApi(filters)
userList.value = data
if (searchKeyword) {
ElMessage.success(`搜索完成,找到 ${data.length} 个匹配的用户`)
if (filters && Object.keys(filters).length > 0) {
ElMessage.success(`找到 ${data.length} 个匹配的用户`)
isSearchMode.value = true
} else {
ElMessage.success('用户列表加载成功')
@@ -120,40 +115,37 @@ async function fetchUserList(searchKeyword?: string) {
}
}
function handleSearch() {
if (!searchForm.keyword.trim()) {
ElMessage.warning('请输入搜索关键词')
return
// 处理GitHub搜索
function handleGitHubSearch(_query: string, filters: Record<string, string>) {
currentFilters.value = filters
fetchUserList(filters)
}
// 执行搜索(通过搜索框)
function executeSearch() {
if (githubSearch.value) {
githubSearch.value.handleSearch()
} else {
handleGitHubSearch(searchQuery.value, currentFilters.value)
}
fetchUserList(searchForm.keyword.trim())
}
// 重置搜索
function handleReset() {
searchForm.keyword = ''
isSearchMode.value = false
fetchUserList() // 重新获取全部用户列表
searchQuery.value = ''
currentFilters.value = {}
fetchUserList()
}
// 刷新列表
function handleRefresh() {
if (isSearchMode.value && searchForm.keyword) {
fetchUserList(searchForm.keyword)
if (isSearchMode.value && Object.keys(currentFilters.value).length > 0) {
fetchUserList(currentFilters.value)
} else {
fetchUserList()
}
}
// function handleAdd() {
// ElMessage.info('新增用户功能待实现')
// }
// function handleEdit(row: UserListItem) {
// ElMessage.info(`编辑用户 ${row.UserName} 功能待实现`)
// }
// function handleDelete(row: UserListItem) {
// ElMessage.info(`删除用户 ${row.UserName} 功能待实现`)
// }
// 页面加载时获取用户列表
onMounted(() => {
fetchUserList()
@@ -164,25 +156,58 @@ onMounted(() => {
.user-management {
padding: 24px;
}
/* 新增flex布局 */
.search-statistics-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
gap: 24px;
}
.search-form {
/* 保持原有样式,可根据需要调整宽度 */
margin-bottom: 0;
.search-container {
width: 600px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
}
.search-container :deep(.github-search-container) {
flex: 1;
min-width: 0;
}
.search-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.statistics {
margin-left: 32px;
display: flex;
gap: 24px;
margin-top: 0;
flex-shrink: 0;
}
.toolbar {
margin-bottom: 16px;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.search-statistics-row {
flex-direction: column;
}
.search-container {
width: 100%;
}
.statistics {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
}
</style>