feat: init

This commit is contained in:
Rogee
2024-09-30 11:02:26 +08:00
parent 679759846b
commit 694dfd2a4f
90 changed files with 6046 additions and 15 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

9
frontend/README.md Normal file
View File

@@ -0,0 +1,9 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (previously Volar) and disable Vetur
- Use [vue-tsc](https://github.com/vuejs/language-tools/tree/master/packages/tsc) for performing the same type checking from the command line, or for generating d.ts files for SFCs.

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<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.ts"></script>
</body>
</html>

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.2",
"moment": "^2.30.1",
"vue": "^3.4.21"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.13",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19",
"daisyui": "^4.11.1",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vue-tsc": "^2.0.6"
}
}

1807
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

41
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from "vue";
import Devices from "./components/Devices.vue";
import Experts from "./components/Experts.vue";
const active = ref<Number>(1);
const activeTab = (index: number) => {
active.value = index;
};
</script>
<template>
<div class="container mx-auto py-10">
<div role="tablist" class="tabs tabs-boxed">
<a
role="tab"
class="tab"
:class="{ 'tab-active': active == 0 }"
@click="activeTab(0)"
>专家管理</a
>
<a
role="tab"
class="tab"
:class="{ 'tab-active': active == 1 }"
@click="activeTab(1)"
>设备管理</a
>
</div>
<div class="tab-data mt-10">
<div v-show="active == 0">
<Experts />
</div>
<div v-show="active == 1">
<Devices />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import axios, { AxiosResponse } from 'axios';
import { onMounted, ref } from 'vue';
interface Device {
ID: number
UUID: string
Name: string
Expert: string
ExpertName: string
State: string
Note: string
}
interface Expert {
ID: number
UID: string
SecUID: string
ShortID: string
RealName: string
NickName: string
State: string
Since: number
Focus: number
Total: number
Voice: string
Hello: string
}
const devices = ref<Device[]>([]);
const experts = ref<Expert[]>([]);
// use axios get /experts onMount
const loadExperts = function () {
axios.get<Expert[]>('/api/experts').then((resp: AxiosResponse) => {
experts.value = resp.data;
});
}
// use axios get /experts onMount
const loadDevices = function () {
axios.get<Device[]>('/api/devices').then((resp: AxiosResponse) => {
devices.value = resp.data;
});
}
onMounted(() => {
loadDevices()
});
setInterval(loadDevices, 30 * 1000);
const currentDevice = ref<Device | null>(null);
const showDeviceUpdateModal = (id: number) => {
loadExperts();
currentDevice.value = devices.value.find((device) => device.ID === id) || null;
selectExpert.value = currentDevice.value?.Expert
deviceName.value = currentDevice.value?.Name
const dialog = document.getElementById("show_device_update_modal") as HTMLDialogElement;
dialog.showModal();
};
const resetStateNormal = (id: number) => {
currentDevice.value = devices.value.find((device) => device.ID === id) || null;
if (!currentDevice.value) {
return;
}
// alert to confirm
if (!confirm(`确定要恢复 ${currentDevice.value.UUID} 的自动关注吗?`)) {
return;
}
const data = { state: "" };
axios.patch(`/api/devices/${currentDevice.value?.UUID}/state`, data).then((resp: AxiosResponse) => {
console.log(resp.data)
loadDevices();
});
};
const setStateStop = (id: number) => {
currentDevice.value = devices.value.find((device) => device.ID === id) || null;
if (!currentDevice.value) {
return;
}
// alert to confirm
if (!confirm(`确定要停止 ${currentDevice.value.UUID} 的自动关注吗?`)) {
return;
}
const data = { state: "stop" };
axios.patch(`/api/devices/${currentDevice.value?.UUID}/state`, data).then((resp: AxiosResponse) => {
console.log(resp.data)
loadDevices();
});
};
const closeModal = function () {
const dialog = document.getElementById("show_device_update_modal") as HTMLDialogElement;
dialog.close();
}
const deviceName = ref<String | null>();
const selectExpert = ref<String | null>();
const saveDate = () => {
const data = {
name: deviceName.value,
};
console.log(data)
axios.patch(`/api/devices/${currentDevice.value?.UUID}/experts/${selectExpert.value}`, data).then((resp: AxiosResponse) => {
console.log(resp.data)
loadDevices();
setTimeout(closeModal, 500)
});
};
</script>
<template>
<dialog id="show_device_update_modal" class="modal">
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg">{{ currentDevice?.UUID }}</h3>
<div class="py-5">
<label class="form-control w-full max-w-xs">
<div class="label">
<span class="label-text">设备名称</span>
</div>
<input type="text" placeholder="设备名称" class="input input-bordered w-full max-w-xs" v-model="deviceName"/>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">选择专家</span>
</div>
<select class="select select-bordered w-full max-w-xs" v-model="selectExpert">
<option v-for="expert in experts" :key="expert.ID" :value="expert.UID">{{ expert.RealName }}</option>
</select>
</label>
</div>
<div class="flex justify-end gap-3">
<button class="btn btn-wide btn-primary" @click="saveDate">保存</button>
</div>
</div>
</dialog>
<div className="overflow-x-auto">
<div v-if="devices.length == 0">
<h1 class="text-lg text-center">还没有设备</h1>
</div>
<table className="table" v-else>
<thead>
<tr>
<th>设备号</th>
<th>名称</th>
<th>专家</th>
<th>停止关注</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr :class='idx % 2 == 1 ? "bg-slate-50" : ""' v-for="(item, idx) in devices" :key="item.ID">
<td class="flex flex-col">
<div class="text-lg font-semibold">{{ item.UUID }}</div>
</td>
<td>{{ item.Name }}</td>
<td>
<button class="btn btn-sm" @click="showDeviceUpdateModal(item.ID)">{{ item.ExpertName }}</button>
</td>
<td>
<button v-if="item.State == 'stop'" class="btn btn-warning btn-sm"
@click="resetStateNormal(item.ID)">恢复</button>
<button v-else class="btn btn-error btn-sm text-white" @click="setStateStop(item.ID)">停止</button>
</td>
<td>{{ item.Note }}</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
import axios, { AxiosResponse } from 'axios';
import moment from 'moment';
import { onMounted, ref } from 'vue';
interface Expert {
ID: number
UID: string
SecUID: string
ShortID: string
RealName: string
NickName: string
State: string
Since: number
Focus: number
Total: number
Conf: Conf
}
interface Conf {
Voice: string
Hello: string
Wechat: string
Region: string[]
NameKeyword: string[]
Produce: boolean
DefaultName: boolean
DefaultAvatar: boolean
}
const experts = ref<Expert[]>([]);
// use axios get /experts onMount
const loadExperts = function () {
axios.get<Expert[]>('/api/experts').then((resp: AxiosResponse) => {
experts.value = resp.data;
});
}
onMounted(loadExperts);
setInterval(loadExperts, 30 * 1000);
const parseTime = function (timestamp: number) {
if (timestamp == 0) {
return '不限制'
}
return moment.unix(timestamp).format('YY/MM/DD HH:mm:ss')
}
const currentExpert = ref<Expert | null>(null);
const showExpertDateModal = (id: number) => {
currentExpert.value = experts.value.find((expert) => expert.ID === id) || null;
date.value = moment.unix(currentExpert.value?.Since || 0).format('YYYY-MM-DDTHH:mm');
voice.value = currentExpert.value?.Conf.Voice || '';
hello.value = currentExpert.value?.Conf.Hello || '';
wechat.value = currentExpert.value?.Conf.Wechat || '';
region.value = (currentExpert.value?.Conf.Region || []).join(',');
produce.value = currentExpert.value?.Conf.Produce || false;
default_name.value = currentExpert.value?.Conf.DefaultName || false;
default_avatar.value = currentExpert.value?.Conf.DefaultAvatar || false;
name_keyword.value = (currentExpert.value?.Conf.NameKeyword || []).join(',');
const dialog = document.getElementById("set_date_modal") as HTMLDialogElement;
dialog.showModal();
};
const resetStateNormal = (id: number) => {
currentExpert.value = experts.value.find((expert) => expert.ID === id) || null;
if (!currentExpert.value) {
return;
}
// alert to confirm
if (!confirm(`确定要恢复 ${currentExpert.value.RealName} 的自动关注吗?`)) {
return;
}
const data = { state: "" };
axios.patch(`/api/experts/${currentExpert.value?.UID}/state`, data).then((resp: AxiosResponse) => {
console.log(resp.data)
loadExperts();
});
};
const setStateStop = (id: number) => {
currentExpert.value = experts.value.find((expert) => expert.ID === id) || null;
if (!currentExpert.value) {
return;
}
// alert to confirm
if (!confirm(`确定要停止 ${currentExpert.value.RealName} 的自动关注吗?`)) {
return;
}
const data = { state: "stop" };
axios.patch(`/api/experts/${currentExpert.value?.UID}/state`, data).then((resp: AxiosResponse) => {
console.log(resp.data)
loadExperts();
});
};
const closeModal = function () {
const dialog = document.getElementById("set_date_modal") as HTMLDialogElement;
dialog.close();
}
const resetDate = () => {
const data = { since: 0 };
axios.patch(`/api/experts/${currentExpert.value?.UID}/config`, data).then((resp: AxiosResponse) => {
console.log(resp.data)
loadExperts();
setTimeout(closeModal, 500)
});
};
const date = ref('2022-02-01T01:10');
const voice = ref('');
const hello = ref('');
const wechat = ref('');
const region = ref('');
const produce = ref(false);
const default_name = ref(false);
const default_avatar = ref(false);
const name_keyword = ref('');
const saveDate = () => {
let regions = region.value.replace(/\s/g, '').replace("", "").split(',');
let name_keywords = name_keyword.value.replace(/\s/g, '').replace("", "").split(',');
const data = {
since: Date.parse(date.value) / 1000,
voice: voice.value,
hello: hello.value,
wechat: wechat.value,
region: regions,
produce: produce.value,
DefaultName: default_name.value,
DefaultAvatar: default_avatar.value,
NameKeyword: name_keywords,
};
console.log(data)
axios.patch(`/api/experts/${currentExpert.value?.UID}/config`, data).then((resp: AxiosResponse) => {
console.log(resp.data)
loadExperts();
setTimeout(closeModal, 500)
});
};
</script>
<template>
<dialog id="set_date_modal" class="modal">
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg">{{ currentExpert?.RealName }}</h3>
<div class="py-5">
<label class="form-control w-full mb-5">
<div class="label">
<span class="label-text">微信号</span>
</div>
<input type="text" placeholder="微信号" class="input input-bordered w-full " v-model="wechat" />
</label>
<label class="form-control w-full mb-5">
<div class="label">
<span class="label-text">语音ID</span>
</div>
<input type="text" placeholder="语音ID" class="input input-bordered w-full " v-model="voice" />
</label>
<label class="form-control w-full " mb-5>
<div class="label">
<span class="label-text">打招呼模板</span>
</div>
<textarea v-model="hello" class="textarea textarea-bordered w-full " placeholder="打招呼模板"></textarea>
</label>
<label class="form-control w-full mb-5">
<div class="label">
<span class="label-text">屏蔽IP区域用户</span>
</div>
<input type="text" placeholder="屏蔽IP区域用户" class="input input-bordered w-full" v-model="region" />
<div class="label">
<span class="label-text-alt">多个区域用 , 号分割</span>
</div>
</label>
<label class="form-control w-full mb-5">
<div class="label">
<span class="label-text">屏蔽用户名关键字</span>
</div>
<input type="text" placeholder="屏蔽用户名关键字" class="input input-bordered w-full" v-model="name_keyword" />
<div class="label">
<span class="label-text-alt">多个用,号分割</span>
</div>
</label>
<label class="form-control w-full ">
<div class="form-control">
<label class="cursor-pointer label justify-start gap-1">
<input type="checkbox" v-model="produce" class="checkbox checkbox-success" />
<span class="label-text">屏蔽0作品用户</span>
</label>
</div>
</label>
<label class="form-control w-full ">
<div class="form-control">
<label class="cursor-pointer label justify-start gap-1">
<input type="checkbox" v-model="default_name" class="checkbox checkbox-success" />
<span class="label-text">屏蔽默认用户名用户</span>
</label>
</div>
</label>
<label class="form-control w-full ">
<div class="form-control">
<label class="cursor-pointer label justify-start gap-1">
<input type="checkbox" v-model="default_avatar" class="checkbox checkbox-success" />
<span class="label-text">屏蔽默认头像名用户</span>
</label>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">选择时间</span>
</div>
<input type="datetime-local" v-model="date" placeholder="请选择时间" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">获取晚于这个时间关注的粉丝数据</span>
</div>
</label>
</div>
<div class="flex justify-end gap-3">
<button class="btn btn-default" @click="resetDate">恢复不限制</button>
<button class="btn btn-wide btn-primary" @click="saveDate">保存</button>
</div>
</div>
</dialog>
<div className="overflow-x-auto">
<div v-if="experts.length == 0">
<h1 class="text-lg text-center">还没有专家</h1>
</div>
<table className="table" v-else>
<thead>
<tr>
<th>专家</th>
<th>已关注/所有</th>
<th>配置</th>
<th>停止关注</th>
</tr>
</thead>
<tbody>
<tr :class='idx % 2 == 1 ? "bg-slate-50" : ""' v-for="(item, idx) in experts" :key="item.ID">
<td class="flex flex-col">
<div class="text-lg font-semibold">{{ item.RealName }}</div>
<div>{{ item.UID }}</div>
</td>
<td>{{ item.Focus }} / {{ item.Total }}</td>
<td>
<button class="btn btn-sm" @click="showExpertDateModal(item.ID)">{{ parseTime(item.Since) }}</button>
</td>
<td>
<button v-if="item.State == 'stop'" class="btn btn-warning btn-sm"
@click="resetStateNormal(item.ID)">恢复</button>
<button v-else class="btn btn-error btn-sm text-white" @click="setStateStop(item.ID)">停止</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>

5
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

3
frontend/src/style.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

6
frontend/static.go Normal file
View File

@@ -0,0 +1,6 @@
package frontend
import "embed"
//go:embed dist
var Static embed.FS

View File

@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
daisyui: {
themes: ["light", "dark"],
},
plugins: [
require("@tailwindcss/typography"),
require('daisyui'),
],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:9090',
changeOrigin: true,
}
}
}
})