app: Init

This commit is contained in:
LittleChest 2025-09-27 09:21:18 +08:00
commit 6e8180db31
12 changed files with 1708 additions and 0 deletions

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

17
eslint.config.js Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
])

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>年龄验证</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

13
jsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"allowJs": true,
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "bundler",
"paths": {
"@/*": ["src/*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
}
}

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "age",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"sass-embedded": "^1.93.2",
"vue": "^3.5.21",
"vuetify": "^3.10.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.5",
"vite-plugin-vuetify": "^2.1.2"
}
}

1407
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

163
src/App.vue Normal file
View File

@ -0,0 +1,163 @@
<template>
<v-app>
<v-main>
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6">
<v-card class="pa-6" elevation="6">
<v-card-title class="justify-center">年龄验证</v-card-title>
<v-card-text>
<div class="flex justify-center text-center">
<video
ref="video"
autoplay
playsinline
muted
width="320"
height="240"
style="border-radius: 8px; background: #000"
v-show="cameraActive && !photoData"
></video>
<img
v-if="photoData"
:src="photoData"
alt="自拍预览"
width="320"
height="240"
style="border-radius: 8px; object-fit: cover"
/>
</div>
<div class="mt-4 flex gap-3 justify-center">
<v-btn color="primary" @click="startCamera" v-if="!cameraActive"
>打开摄像头</v-btn
>
<v-btn color="primary" @click="takePhoto" v-if="cameraActive && !photoData"
>拍照</v-btn
>
<v-btn color="secondary" @click="retake" v-if="photoData">重拍</v-btn>
<v-btn color="success" @click="uploadPhoto" :disabled="!photoBlob">上传</v-btn>
<label>
<input
type="file"
accept="image/*"
@change="onFileChange"
class="hidden"
ref="fileInput"
/>
<v-btn color="info" @click="$refs.fileInput.click()">从相册选择</v-btn>
</label>
</div>
<v-progress-linear
:indeterminate="true"
v-if="loading"
class="mt-4"
></v-progress-linear>
<div v-if="responseText" class="mt-4">
<div>调试信息</div>
<pre class="bg-gray-100 p-3 rounded overflow-auto">{{ responseText }}</pre>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script>
export default {
data() {
return {
cameraActive: false,
stream: null,
photoData: null,
photoBlob: null,
loading: false,
responseText: '',
}
},
methods: {
async startCamera() {
this.stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user' },
audio: false,
})
this.cameraActive = true
await this.$nextTick()
const videoEl = this.$refs.video
videoEl.srcObject = this.stream
await videoEl.play().catch(() => {})
},
takePhoto() {
const video = this.$refs.video
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth || 320
canvas.height = video.videoHeight || 240
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
canvas.toBlob(
(blob) => {
this.photoBlob = blob
this.photoData = URL.createObjectURL(blob)
},
'image/jpeg',
0.9
)
this.stopCamera()
},
retake() {
if (this.photoData) URL.revokeObjectURL(this.photoData)
this.photoData = null
this.photoBlob = null
this.startCamera()
},
stopCamera() {
if (this.stream) {
this.stream.getTracks().forEach((t) => t.stop())
this.stream = null
}
this.cameraActive = false
},
onFileChange(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
this.photoBlob = file
this.photoData = URL.createObjectURL(file)
},
async uploadPhoto() {
if (!this.photoBlob) return alert('请先拍照或选择图片')
this.loading = true
this.responseText = ''
try {
const form = new FormData()
form.append('face', this.photoBlob, 'selfie.jpg')
const resp = await fetch('https://api.littlew.top/age', {
method: 'POST',
body: form,
})
const text = await resp.text()
try {
const obj = JSON.parse(text)
this.responseText = JSON.stringify(obj, null, 2)
} catch (e) {
this.responseText = text
}
} catch (e) {
this.responseText = e.message
} finally {
this.loading = false
}
},
},
beforeUnmount() {
this.stopCamera()
},
}
</script>

19
src/main.js Normal file
View File

@ -0,0 +1,19 @@
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import { createApp } from 'vue'
import { createVuetify } from 'vuetify'
import App from './App.vue'
const app = createApp(App)
app.use(
createVuetify({
theme: {
defaultTheme: 'system',
},
})
)
app.mount('#app')

23
vite.config.mjs Normal file
View File

@ -0,0 +1,23 @@
import Vue from '@vitejs/plugin-vue'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
Vue({
template: { transformAssetUrls },
}),
Vuetify(),
],
optimizeDeps: {
exclude: ['vuetify'],
},
resolve: {
alias: {
'@': fileURLToPath(new URL('src', import.meta.url)),
},
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
},
})