Rename projects to FileShare

This commit is contained in:
2026-05-22 14:29:22 +08:00
parent 8270cf198b
commit 9f8da2c063
154 changed files with 394 additions and 398 deletions
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import AdminPage from './components/AdminPage.vue'
import ClientPage from './components/ClientPage.vue'
const isAdminPage = computed(() => window.location.pathname.toLowerCase().startsWith('/admin'))
</script>
<template>
<AdminPage v-if="isAdminPage" />
<ClientPage v-else />
</template>
+16
View File
@@ -0,0 +1,16 @@
// 扩展 Window 接口,声明 C# 桥接注入的全局属性
declare global {
interface Window {
/** 由 C# BridgeScript 注入,标记当前运行在 WebView2 环境中 */
isWebView2?: boolean
/** 由 WebView2 宿主注入,用于向 C# 发送消息 */
invokeCSharpAction?: (message: string) => void
__pcMediaOrigin?: string
}
}
// 判断当前是否运行在 WebView2 环境中
// 参考 www/index.html 中的判断逻辑
export const isWebView2 = (): boolean =>
window.isWebView2 === true ||
typeof window.invokeCSharpAction === 'function'
+97
View File
@@ -0,0 +1,97 @@
import axios from 'axios'
import { isWebView2 } from './env'
// WebView2 自定义协议前缀
const WEBVIEW2_BASE = 'app://api/'
// Vite 开发页走 5206 API;API 托管前端时使用同源地址。
const isViteDevServer = window.location.port === '51552'
const HTTP_ORIGIN = isViteDevServer
? `${window.location.protocol}//${window.location.hostname || 'localhost'}:5206`
: window.location.origin
const HTTP_BASE = `${HTTP_ORIGIN}/api/`
export const apiOrigin = (): string => HTTP_ORIGIN
export const apiUrl = (path: string): string => {
if (/^https?:\/\//i.test(path)) return path
const normalized = path.startsWith('/') ? path : `/${path}`
if (isWebView2() && window.__pcMediaOrigin) {
return `${window.__pcMediaOrigin}${normalized}`
}
return `${isWebView2() ? '' : HTTP_ORIGIN}${normalized}`
}
// ─── axios 实例 ────────────────────────────────────────────────────────────────
const http = axios.create({
headers: { 'Content-Type': 'application/json' },
})
// 请求拦截器:仅在浏览器环境下注入鉴权 Token
// WebView2 本地运行,不需要鉴权
http.interceptors.request.use((config) => {
if (!isWebView2()) {
const token = localStorage.getItem('authToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
}
return config
})
// 响应拦截器:统一解包 C# 返回的 { success, data/error } 结构
// C# BuildSuccessResponseBody 固定格式:{ "success": true, "data": ... }
// 错误格式:{ "success": false, "error": "..." }
// WebView2 桥接和 HTTP 两个环境返回结构相同,拦截器可统一处理
http.interceptors.response.use(
(response) => {
const payload = response.data as { success: boolean; data?: unknown; error?: string; message?: string }
if (payload?.success === false) {
return Promise.reject(new Error(payload.error ?? payload.message ?? '请求失败'))
}
return (payload?.data ?? payload) as never
},
(error) => {
const msg: string =
error.response?.data?.error ??
error.response?.data?.message ??
error.message ??
'网络错误'
return Promise.reject(new Error(msg))
},
)
// ─── 统一请求方法 ──────────────────────────────────────────────────────────────
interface RequestOptions {
method?: string
headers?: Record<string, string>
body?: unknown
}
export async function request<T = unknown>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const url = (isWebView2() ? WEBVIEW2_BASE : HTTP_BASE) + endpoint
// WebView2:直接走桥接 fetch(桥接脚本已完整覆盖 window.fetch
if (isWebView2()) {
const res = await fetch(url, {
method: options.method ?? 'GET',
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
const payload = await res.json() as { success: boolean; data?: T; error?: string; message?: string }
if (payload?.success === false) {
throw new Error(payload.error ?? payload.message ?? '请求失败')
}
return (payload?.data ?? payload) as T
}
// 普通浏览器:走 axios(拦截器处理鉴权和响应解包)
return http.request<T>({
url,
method: options.method ?? 'GET',
headers: options.headers,
data: options.body,
}) as Promise<T>
}
+102
View File
@@ -0,0 +1,102 @@
import { apiUrl, request } from './http'
export type MediaType = 'all' | 'text' | 'video' | 'audio'
export interface DriveDto {
name: string
displayName: string
rootDirectory: string
driveType: string
totalSize: number | null
availableFreeSpace: number | null
isReady: boolean
}
export interface DirectoryDto {
name: string
fullPath: string
}
export interface LibraryRootDto {
id: number
path: string
displayName: string
isEnabled: boolean
isAvailable: boolean
scanIntervalMinutes: number
lastScanStartedAt: string | null
lastScanCompletedAt: string | null
lastScanError: string | null
fileCount: number
}
export interface FileRecordDto {
id: number
libraryRootId: number
fileName: string
relativePath: string
extension: string
sizeBytes: number
lastWriteTimeUtc: string
mediaType: 'text' | 'video' | 'audio'
contentType: string
streamUrl: string
textUrl: string | null
browserPlayable: boolean
}
export interface BrowseDirectoryResponse {
currentPath: string
subdirectories: string[]
files: FileRecordDto[]
}
export interface TextPreviewDto {
id: number
fileName: string
content: string
truncated: boolean
}
export interface PagedResponse<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
const qs = (params: Record<string, string | number | undefined | null>) => {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
}
const value = search.toString()
return value ? `?${value}` : ''
}
// 业务接口定义,新增接口在此处添加一行即可
export const api = {
getUser: () => request('getUser'),
processData: (input: string) => request('processData', { method: 'POST', body: { input } }),
wData: (input: string) => request('wData', { method: 'POST', body: { input } }),
getDrives: () => request<DriveDto[]>('library/drives'),
getDirectories: (path: string) => request<DirectoryDto[]>(`library/directories${qs({ path })}`),
getRoots: () => request<LibraryRootDto[]>('library/roots'),
addRoot: (body: { path: string; displayName?: string; scanIntervalMinutes?: number }) =>
request<LibraryRootDto>('library/roots', { method: 'POST', body }),
setRootEnabled: (id: number, isEnabled: boolean) =>
request<LibraryRootDto>('library/roots/enabled', { method: 'POST', body: { id, isEnabled } }),
deleteRoot: (id: number) =>
request('library/roots/delete', { method: 'POST', body: { id } }),
scanRoot: (id: number) =>
request<LibraryRootDto>('library/roots/scan', { method: 'POST', body: { id } }),
searchFiles: (params: { page: number; pageSize: number; mediaType?: MediaType; keyword?: string; rootId?: number }) =>
request<PagedResponse<FileRecordDto>>(`files${qs(params)}`),
browseDirectory: (rootId: number, path: string) =>
request<BrowseDirectoryResponse>(`files/browse${qs({ rootId, path })}`),
getTextPreview: (id: number) =>
request<TextPreviewDto>(`files/text${qs({ id })}`),
mediaUrl: (path: string) => apiUrl(path),
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
}
+86
View File
@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

+781
View File
@@ -0,0 +1,781 @@
@import './base.css';
:root {
--page: #f5f7fb;
--panel: #ffffff;
--panel-strong: #101828;
--line: #d9e1ea;
--text: #17202c;
--muted: #667085;
--accent: #0f766e;
--accent-strong: #115e59;
--danger: #b42318;
--danger-bg: #fff2f0;
--shadow: 0 18px 45px rgba(16, 24, 40, 0.10);
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
background: var(--page);
color: var(--text);
}
button,
input,
select {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button {
min-height: 38px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 13px;
color: var(--text);
background: #fff;
cursor: pointer;
}
button:hover:not(:disabled) {
border-color: var(--accent);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
input,
select {
width: 100%;
min-height: 42px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 12px;
color: var(--text);
background: #fff;
}
a {
color: inherit;
text-decoration: none;
}
#app {
min-height: 100vh;
}
.primary-button {
color: #fff;
border-color: var(--accent);
background: var(--accent);
}
.secondary-button {
color: var(--text);
background: #fff;
}
.danger-button {
color: var(--danger);
}
.text-button {
min-height: 30px;
border: none;
padding: 0;
color: var(--accent);
background: transparent;
}
.error-banner {
border: 1px solid #f1b8b2;
border-radius: 10px;
padding: 10px 12px;
color: var(--danger);
background: var(--danger-bg);
}
.empty-state,
.hint,
.unsupported {
color: var(--muted);
}
.admin-shell {
width: min(1480px, 100%);
margin: 0 auto;
padding: 28px;
}
.admin-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 28px;
align-items: end;
border-radius: 18px;
padding: 34px;
color: #fff;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(16, 24, 40, 0.98)),
#101828;
box-shadow: var(--shadow);
}
.eyebrow {
margin: 0 0 10px;
color: #a7f3d0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.admin-hero h1 {
margin: 0;
font-size: 36px;
font-weight: 800;
}
.admin-hero p {
max-width: 720px;
margin: 12px 0 0;
color: #d9f5ef;
}
.admin-metrics {
display: grid;
grid-template-columns: repeat(3, 120px);
gap: 12px;
}
.admin-metrics div {
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 14px;
padding: 16px;
background: rgba(255, 255, 255, 0.10);
}
.admin-metrics strong {
display: block;
font-size: 28px;
font-weight: 800;
}
.admin-metrics span {
color: #d9f5ef;
font-size: 13px;
}
.admin-layout {
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.1fr);
gap: 18px;
margin-top: 18px;
}
.admin-card {
border: 1px solid var(--line);
border-radius: 16px;
padding: 22px;
background: var(--panel);
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.05);
}
.card-heading,
.browser-header,
.inline-form,
.root-actions,
.root-main,
.root-meta,
.mobile-header,
.filter-row,
.player-title,
.mobile-pager {
display: flex;
align-items: center;
}
.card-heading,
.browser-header,
.mobile-header,
.player-title {
justify-content: space-between;
gap: 14px;
}
.card-heading h2,
.drive-list h3,
.directory-list h3 {
margin: 0;
font-size: 20px;
font-weight: 800;
}
.card-heading p {
margin: 5px 0 0;
color: var(--muted);
}
.client-link,
.admin-link {
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 14px;
color: var(--accent-strong);
background: #fff;
}
.field {
display: grid;
gap: 8px;
margin-top: 20px;
}
.field span {
color: var(--muted);
font-size: 13px;
}
.inline-form {
gap: 10px;
}
.inline-form button {
flex: 0 0 auto;
}
.admin-browser {
display: grid;
grid-template-columns: 0.8fr 1.2fr;
gap: 14px;
margin-top: 22px;
}
.drive-list,
.directory-list {
display: grid;
align-content: start;
gap: 10px;
min-height: 420px;
}
.drive-row,
.directory-row {
display: grid;
justify-items: start;
width: 100%;
min-height: 54px;
text-align: left;
}
.drive-row span,
.directory-row {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.drive-row small,
.current-path {
color: var(--muted);
font-size: 13px;
}
.current-path {
overflow-wrap: anywhere;
margin: 0;
}
.root-table {
display: grid;
gap: 12px;
margin-top: 20px;
}
.root-item {
display: grid;
gap: 12px;
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
background: #fff;
}
.root-main {
gap: 12px;
min-width: 0;
}
.root-main strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 800;
}
.root-main p,
.root-error {
margin: 4px 0 0;
overflow-wrap: anywhere;
color: var(--muted);
}
.root-error {
color: var(--danger);
}
.root-meta {
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 13px;
}
.root-meta span {
border-radius: 999px;
padding: 4px 9px;
background: #f2f4f7;
}
.root-actions {
flex-wrap: wrap;
gap: 8px;
}
.status-pill {
flex: 0 0 auto;
border-radius: 999px;
padding: 5px 10px;
font-size: 13px;
font-weight: 800;
}
.status-pill.ok {
color: #067647;
background: #dcfae6;
}
.status-pill.bad {
color: var(--danger);
background: var(--danger-bg);
}
.client-shell {
width: min(860px, 100%);
margin: 0 auto;
padding: 14px;
}
.mobile-header {
position: sticky;
top: 0;
z-index: 5;
margin: -14px -14px 12px;
padding: 16px 14px 12px;
background: rgba(245, 247, 251, 0.94);
backdrop-filter: blur(12px);
}
.mobile-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.back-button {
border-radius: 999px;
color: var(--accent-strong);
}
.mobile-header h1 {
margin: 0;
font-size: 28px;
font-weight: 850;
}
.mobile-header p,
.player-title p,
.mobile-file small {
margin: 3px 0 0;
color: var(--muted);
}
.mobile-filters,
.player-panel,
.mobile-list,
.root-picker {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
background: var(--panel);
}
.root-picker {
display: grid;
gap: 10px;
}
.root-tile {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 12px;
width: 100%;
min-height: 76px;
padding: 13px;
text-align: left;
}
.root-tile span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 850;
}
.root-tile strong {
grid-row: span 2;
align-self: center;
color: var(--accent-strong);
font-size: 24px;
font-weight: 850;
}
.root-tile small {
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-row {
gap: 8px;
margin-top: 8px;
}
.filter-row select {
min-width: 0;
}
.filter-row button {
flex: 0 0 auto;
}
.player-panel {
margin-top: 12px;
}
.player-title {
align-items: flex-start;
}
.player-title h2 {
margin: 0;
overflow-wrap: anywhere;
font-size: 18px;
font-weight: 800;
}
.player-title span {
flex: 0 0 auto;
border-radius: 999px;
padding: 4px 9px;
color: var(--accent-strong);
background: #ccfbef;
font-size: 12px;
font-weight: 800;
}
.player-panel video {
width: 100%;
max-height: 54vh;
margin-top: 12px;
border-radius: 10px;
background: #111827;
}
.player-panel audio {
width: 100%;
margin-top: 18px;
}
.open-media-link {
display: block;
width: 100%;
margin-top: 10px;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
color: var(--accent-strong);
background: #f0fdfa;
text-align: center;
font-weight: 800;
}
.player-panel pre {
overflow: auto;
max-height: 54vh;
margin: 12px 0 0;
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px;
background: #f8fafc;
color: #111827;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mobile-list {
display: grid;
gap: 9px;
margin-top: 12px;
padding: 8px;
}
.mobile-file {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
min-height: 68px;
padding: 10px;
text-align: left;
}
.mobile-file.active {
border-color: var(--accent);
background: #ecfdf5;
}
.mobile-file > span:last-child {
display: grid;
min-width: 0;
}
.mobile-file strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 800;
}
.type-badge {
flex: 0 0 52px;
border-radius: 999px;
padding: 4px 8px;
color: #fff;
background: var(--accent-strong);
text-align: center;
font-size: 12px;
font-weight: 800;
}
.mobile-pager {
justify-content: center;
gap: 10px;
padding: 14px 0 4px;
}
.empty-state,
.unsupported {
margin: 22px 0;
text-align: center;
}
.qr-button {
border-radius: 999px;
padding: 8px 14px;
color: var(--accent-strong);
background: #fff;
}
.qr-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 24, 40, 0.55);
backdrop-filter: blur(6px);
}
.qr-modal {
display: grid;
justify-items: center;
gap: 16px;
border-radius: 18px;
padding: 28px 24px 22px;
background: #fff;
box-shadow: 0 24px 60px rgba(16, 24, 40, 0.22);
text-align: center;
}
.qr-modal h2 {
margin: 0;
font-size: 20px;
font-weight: 800;
}
.qr-image {
display: block;
width: 240px;
height: 240px;
border: 1px solid var(--line);
border-radius: 12px;
}
.qr-hint {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.qr-close {
min-width: 120px;
}
.breadcrumb-nav {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 12px;
padding: 0;
}
.breadcrumb-item {
min-height: 30px;
border: none;
padding: 4px 8px;
color: var(--muted);
background: transparent;
font-size: 14px;
font-weight: 600;
}
.breadcrumb-item:hover:not(:disabled) {
color: var(--accent-strong);
border-color: transparent;
}
.breadcrumb-item.active {
color: var(--text);
font-weight: 800;
}
.breadcrumb-sep {
color: var(--muted);
font-size: 14px;
}
.browse-content {
display: grid;
gap: 14px;
}
.browse-section h3 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 800;
color: var(--muted);
}
.folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.folder-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 46px;
padding: 8px 12px;
text-align: left;
font-weight: 600;
}
.folder-icon {
font-size: 20px;
}
.file-list {
display: grid;
gap: 8px;
border: 1px solid var(--line);
border-radius: 14px;
padding: 8px;
background: var(--panel);
}
@media (max-width: 1100px) {
.admin-layout,
.admin-browser {
grid-template-columns: 1fr;
}
.admin-metrics {
grid-template-columns: repeat(3, minmax(90px, 1fr));
}
}
@media (max-width: 780px) {
.admin-shell {
padding: 14px;
}
.admin-hero {
grid-template-columns: 1fr;
padding: 22px;
}
.admin-hero h1 {
font-size: 30px;
}
.admin-metrics {
grid-template-columns: 1fr;
}
.inline-form,
.filter-row {
align-items: stretch;
flex-direction: column;
}
.inline-form button,
.filter-row button {
width: 100%;
}
}
@media (min-width: 900px) {
.client-shell {
padding-top: 24px;
}
.mobile-header {
position: static;
margin: 0 0 14px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 18px;
background: var(--panel);
}
}
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type DirectoryDto, type DriveDto, type LibraryRootDto } from '../api'
const roots = ref<LibraryRootDto[]>([])
const drives = ref<DriveDto[]>([])
const directories = ref<DirectoryDto[]>([])
const currentPath = ref('')
const manualPath = ref('')
const loading = ref(false)
const scanningId = ref<number | null>(null)
const errorMessage = ref('')
const activeRoots = ref<LibraryRootDto[]>([])
const totalRootFiles = ref(0)
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`
}
function formatDate(value: string | null) {
if (!value) return '未扫描'
return new Date(value).toLocaleString()
}
function setError(error: unknown) {
errorMessage.value = error instanceof Error ? error.message : '操作失败'
}
async function refreshAll() {
await Promise.all([loadRoots(), drives.value.length > 0 ? Promise.resolve() : loadDrives()])
}
async function loadRoots() {
roots.value = await api.getRoots()
activeRoots.value = roots.value.filter((root) => root.isEnabled && root.isAvailable)
totalRootFiles.value = roots.value.reduce((sum, root) => sum + root.fileCount, 0)
}
async function loadDrives() {
drives.value = await api.getDrives()
}
async function openDirectory(path: string) {
try {
errorMessage.value = ''
currentPath.value = path
manualPath.value = path
directories.value = await api.getDirectories(path)
} catch (error) {
setError(error)
}
}
async function addRoot(path = manualPath.value) {
try {
errorMessage.value = ''
loading.value = true
await api.addRoot({ path, scanIntervalMinutes: 5 })
await loadRoots()
} catch (error) {
setError(error)
} finally {
loading.value = false
}
}
async function toggleRoot(root: LibraryRootDto) {
try {
errorMessage.value = ''
await api.setRootEnabled(root.id, !root.isEnabled)
await loadRoots()
} catch (error) {
setError(error)
}
}
async function deleteRoot(root: LibraryRootDto) {
try {
errorMessage.value = ''
await api.deleteRoot(root.id)
await loadRoots()
} catch (error) {
setError(error)
}
}
async function scanRoot(root: LibraryRootDto) {
try {
errorMessage.value = ''
scanningId.value = root.id
await api.scanRoot(root.id)
await loadRoots()
} catch (error) {
setError(error)
} finally {
scanningId.value = null
}
}
onMounted(async () => {
loading.value = true
try {
await Promise.all([loadRoots(), loadDrives()])
} catch (error) {
setError(error)
} finally {
loading.value = false
}
})
</script>
<template>
<main class="admin-shell">
<header class="admin-hero">
<div>
<p class="eyebrow">FileShare Admin</p>
<h1>文件库管理</h1>
<p>添加服务器本机磁盘或目录系统按状态定时扫描异常目录会自动下线并停止自动扫描</p>
</div>
<div class="admin-metrics">
<div>
<strong>{{ roots.length }}</strong>
<span>目录</span>
</div>
<div>
<strong>{{ activeRoots.length }}</strong>
<span>正常启用</span>
</div>
<div>
<strong>{{ totalRootFiles }}</strong>
<span>入库文件</span>
</div>
</div>
</header>
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<section class="admin-layout">
<section class="admin-card path-card">
<div class="card-heading">
<div>
<h2>添加扫描目录</h2>
<p>选择服务器路径或直接输入绝对路径</p>
</div>
<a href="/" class="client-link">客户端</a>
</div>
<label class="field">
<span>服务器路径</span>
<div class="inline-form">
<input v-model="manualPath" type="text" placeholder="例如 D:\Media 或 E:\" />
<button class="primary-button" type="button" :disabled="loading || !manualPath" @click="addRoot()">添加并扫描</button>
</div>
</label>
<div class="admin-browser">
<div class="drive-list">
<h3>磁盘</h3>
<button
v-for="drive in drives"
:key="drive.name"
class="drive-row"
type="button"
:disabled="!drive.isReady"
@click="openDirectory(drive.rootDirectory)"
>
<span>{{ drive.displayName }}</span>
<small>{{ drive.driveType }} · {{ drive.availableFreeSpace !== null ? formatSize(drive.availableFreeSpace) : '不可用' }}</small>
</button>
</div>
<div class="directory-list">
<div class="browser-header">
<h3>目录</h3>
<button type="button" class="text-button" :disabled="!currentPath" @click="addRoot(currentPath)">添加当前目录</button>
</div>
<p class="current-path">{{ currentPath || '请选择一个磁盘' }}</p>
<button
v-for="directory in directories"
:key="directory.fullPath"
class="directory-row"
type="button"
@click="openDirectory(directory.fullPath)"
>
{{ directory.name }}
</button>
</div>
</div>
</section>
<section class="admin-card roots-card">
<div class="card-heading">
<div>
<h2>目录状态</h2>
<p>异常目录不会自动扫描客户端也无法访问其中的文件手动扫描成功后恢复正常</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="refreshAll">刷新</button>
</div>
<div class="root-table">
<article v-for="root in roots" :key="root.id" class="root-item">
<div class="root-main">
<span :class="['status-pill', root.isAvailable ? 'ok' : 'bad']">{{ root.isAvailable ? '正常' : '异常' }}</span>
<div>
<strong>{{ root.displayName }}</strong>
<p>{{ root.path }}</p>
</div>
</div>
<div class="root-meta">
<span>{{ root.fileCount }} 个文件</span>
<span>{{ formatDate(root.lastScanCompletedAt) }}</span>
<span>{{ root.isEnabled ? '启用' : '停用' }}</span>
</div>
<p v-if="root.lastScanError" class="root-error">{{ root.lastScanError }}</p>
<div class="root-actions">
<button type="button" class="secondary-button" @click="scanRoot(root)">
{{ scanningId === root.id ? '扫描中' : root.isAvailable ? '立即扫描' : '手动扫描恢复' }}
</button>
<button type="button" class="secondary-button" :disabled="!root.isAvailable" @click="toggleRoot(root)">
{{ root.isEnabled ? '停用' : '启用' }}
</button>
<button type="button" class="danger-button" @click="deleteRoot(root)">删除</button>
</div>
</article>
<p v-if="roots.length === 0" class="empty-state">还没有添加扫描目录</p>
</div>
</section>
</section>
</main>
</template>
@@ -0,0 +1,288 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api, type BrowseDirectoryResponse, type FileRecordDto, type LibraryRootDto, type TextPreviewDto } from '../api'
import QrCodeModal from './QrCodeModal.vue'
const roots = ref<LibraryRootDto[]>([])
const browseData = ref<BrowseDirectoryResponse | null>(null)
const selectedFile = ref<FileRecordDto | null>(null)
const textPreview = ref<TextPreviewDto | null>(null)
const loading = ref(false)
const errorMessage = ref('')
// Navigation state
const rootId = ref<number | undefined>()
const browsePath = ref<string[]>([])
const isBrowsingRoots = ref(true)
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
const selectedRoot = computed(() => roots.value.find((root) => root.id === selectedFile.value?.libraryRootId))
const currentBrowsePath = computed(() => browsePath.value.join('/'))
const breadcrumbs = computed(() => {
const root = roots.value.find((r) => r.id === rootId.value)
const items = [{ label: root?.displayName ?? '文件库', path: '' }]
for (let i = 0; i < browsePath.value.length; i++) {
items.push({
label: browsePath.value[i],
path: browsePath.value.slice(0, i + 1).join('/'),
})
}
return items
})
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
const clientTitle = computed(() => {
if (isBrowsingRoots.value) return '文件库'
const root = roots.value.find((r) => r.id === rootId.value)
const dir = browsePath.value.length > 0 ? browsePath.value[browsePath.value.length - 1] : ''
return dir || (root?.displayName ?? '文件')
})
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`
}
function formatDate(value: string | null) {
if (!value) return ''
return new Date(value).toLocaleString()
}
function setError(error: unknown) {
errorMessage.value = error instanceof Error ? error.message : '操作失败'
}
async function loadRoots() {
roots.value = await api.getRoots()
}
async function browseDirectory() {
if (!rootId.value) return
try {
errorMessage.value = ''
loading.value = true
browseData.value = await api.browseDirectory(rootId.value, currentBrowsePath.value)
} catch (error) {
setError(error)
} finally {
loading.value = false
}
}
async function enterRoot(id: number) {
rootId.value = id
isBrowsingRoots.value = false
browsePath.value = []
selectedFile.value = null
textPreview.value = null
await browseDirectory()
}
function backToRoots() {
isBrowsingRoots.value = true
rootId.value = undefined
browseData.value = null
browsePath.value = []
selectedFile.value = null
textPreview.value = null
}
async function navigateTo(path: string) {
if (path === '') {
browsePath.value = []
} else {
browsePath.value = path.split('/')
}
selectedFile.value = null
textPreview.value = null
await browseDirectory()
}
async function enterSubdirectory(name: string) {
browsePath.value.push(name)
selectedFile.value = null
textPreview.value = null
await browseDirectory()
}
async function selectFile(file: FileRecordDto) {
selectedFile.value = file
textPreview.value = null
if (file.mediaType !== 'text') return
try {
textPreview.value = await api.getTextPreview(file.id)
} catch (error) {
setError(error)
}
}
onMounted(async () => {
loading.value = true
try {
await loadRoots()
} catch (error) {
setError(error)
} finally {
loading.value = false
}
})
</script>
<template>
<main class="client-shell">
<header class="mobile-header">
<div>
<h1>{{ clientTitle }}</h1>
<p>
<template v-if="isBrowsingRoots">{{ activeRoots.length }} 个目录</template>
<template v-else-if="browseData">
{{ browseData.subdirectories.length }} 个文件夹 · {{ browseData.files.length }} 个文件
</template>
</p>
</div>
<div class="mobile-header-actions">
<button v-if="!isBrowsingRoots" type="button" class="back-button" @click="backToRoots">返回</button>
<button type="button" class="qr-button" title="生成二维码" @click="qrModal?.open()">二维码</button>
<a href="/admin" class="admin-link">管理</a>
</div>
</header>
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<!-- Library root tiles -->
<section v-if="isBrowsingRoots" class="root-picker">
<button
v-for="root in activeRoots"
:key="root.id"
class="root-tile"
type="button"
@click="enterRoot(root.id)"
>
<span>{{ root.displayName }}</span>
<strong>{{ root.fileCount }}</strong>
<small>{{ root.path }}</small>
</button>
<p v-if="activeRoots.length === 0" class="empty-state">暂无可访问目录</p>
</section>
<!-- Directory browser -->
<template v-else>
<!-- Breadcrumb -->
<nav class="breadcrumb-nav">
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
<span v-if="index > 0" class="breadcrumb-sep">/</span>
<button
type="button"
class="breadcrumb-item"
:class="{ active: index === breadcrumbs.length - 1 }"
@click="navigateTo(crumb.path)"
>
{{ crumb.label }}
</button>
</template>
</nav>
<section v-if="browseData" class="browse-content">
<!-- Subdirectories -->
<section v-if="browseData.subdirectories.length > 0" class="browse-section">
<h3>文件夹</h3>
<div class="folder-grid">
<button
v-for="dir in browseData.subdirectories"
:key="dir"
type="button"
class="folder-item"
@click="enterSubdirectory(dir)"
>
<span class="folder-icon">&#128193;</span>
<span>{{ dir }}</span>
</button>
</div>
</section>
<!-- Media player -->
<section v-if="selectedFile" class="player-panel">
<div class="player-title">
<div>
<h2>{{ selectedFile.fileName }}</h2>
<p>{{ selectedRoot?.displayName ?? '文件库' }} · {{ selectedFile.relativePath }}</p>
</div>
<span>{{ selectedFile.extension }}</span>
</div>
<video
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
:key="selectedFile.id"
controls
playsinline
webkit-playsinline
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</video>
<audio
v-else-if="selectedFile.mediaType === 'audio' && selectedFile.browserPlayable"
:key="selectedFile.id"
controls
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</audio>
<pre v-else-if="selectedFile.mediaType === 'text'">{{ textPreview?.content ?? '加载中...' }}</pre>
<p v-else class="unsupported">浏览器不支持在线播放此格式</p>
<a
v-if="selectedFile.mediaType !== 'text'"
class="open-media-link"
:href="selectedMediaUrl"
target="_blank"
rel="noreferrer"
>
新窗口打开原视频/音频
</a>
<p v-if="textPreview?.truncated" class="hint">文本超过 1 MB已截断显示</p>
</section>
<!-- Files -->
<section v-if="browseData.files.length > 0" class="browse-section">
<h3>文件</h3>
<div class="file-list">
<button
v-for="file in browseData.files"
:key="file.id"
class="mobile-file"
:class="{ active: selectedFile?.id === file.id }"
type="button"
@click="selectFile(file)"
>
<span class="type-badge">{{ file.mediaType }}</span>
<span>
<strong>{{ file.fileName }}</strong>
<small>{{ formatSize(file.sizeBytes) }} · {{ formatDate(file.lastWriteTimeUtc) }}</small>
</span>
</button>
</div>
</section>
<p v-if="browseData.subdirectories.length === 0 && browseData.files.length === 0" class="empty-state">
此目录下没有支持的文件
</p>
</section>
<p v-else-if="!loading" class="empty-state">加载中...</p>
</template>
</main>
<QrCodeModal ref="qrModal" />
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ref } from 'vue'
import { api } from '../api'
const visible = ref(false)
const qrCodeData = ref<{ url: string; qrCodeBase64: string } | null>(null)
const error = ref('')
async function open() {
try {
error.value = ''
qrCodeData.value = await api.qrCode()
visible.value = true
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
}
}
function close() {
visible.value = false
}
defineExpose({ open })
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="qr-overlay" @click.self="close">
<div class="qr-modal">
<h2>扫码访问</h2>
<p v-if="error" class="error-banner">{{ error }}</p>
<img v-if="qrCodeData" :src="qrCodeData.qrCodeBase64" alt="QR Code" class="qr-image" />
<p v-else-if="!error" class="qr-hint">加载中...</p>
<p class="qr-hint">使用手机扫描二维码即可在局域网中打开此网站</p>
<button type="button" class="primary-button qr-close" @click="close">关闭</button>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
+6
View File
@@ -0,0 +1,6 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
+6
View File
@@ -0,0 +1,6 @@
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}