Compare commits

...

12 Commits

Author SHA1 Message Date
CrescentLeaf
633cfed87b feat: setNickName setUserName getMyInfo 2025-09-13 00:40:32 +08:00
CrescentLeaf
c51a6508e4 feat: access myUserProfile through Client 2025-09-13 00:40:09 +08:00
CrescentLeaf
12c2e13505 feat(wip): user profile dialog 2025-09-13 00:39:58 +08:00
CrescentLeaf
372e71bc1c chore: make User.ts declare better 2025-09-13 00:39:25 +08:00
CrescentLeaf
5fee5dd363 chore: useless change 2025-09-13 00:39:03 +08:00
CrescentLeaf
2ee73416e0 chore: change vite config: sourcemap: inline -> true 2025-09-13 00:38:51 +08:00
CrescentLeaf
73a1536df7 chore: add new Api declaretion 2025-09-13 00:38:17 +08:00
CrescentLeaf
8ebad65140 chore: import Avatar.jsx -> .tsx 2025-09-13 00:37:56 +08:00
CrescentLeaf
6896a1f8af refactor: Avatar.jsx -> .tsx 2025-09-13 00:37:25 +08:00
CrescentLeaf
b30035d5a8 feat: access uploaded files through http 2025-09-13 00:37:08 +08:00
CrescentLeaf
6b0e781fdf fix: file upload failed by folder not created 2025-09-13 00:36:48 +08:00
CrescentLeaf
fd6ceb82df chore: remove useless & add getAvatarFileHash 2025-09-13 00:36:12 +08:00
17 changed files with 283 additions and 52 deletions

View File

@@ -1,7 +1,15 @@
export type CallMethod =
"User.auth" |
"User.register" |
"User.login"
"User.login" |
"User.setNickName" |
"User.setUserName" |
"User.setAvatar" |
"User.getMyInfo" |
"Chat.sendMessage" |
"Chat.getMessageHistory"
export type ClientEvent =
"Client.onMessage"

View File

@@ -1,10 +1,12 @@
import { io, Socket } from 'socket.io-client'
import { CallMethod, ClientEvent } from './ApiDeclare.ts'
import ApiCallbackMessage from './ApiCallbackMessage.ts'
import User from "./client_data/User.ts"
type UnknownObject = { [key: string]: unknown }
class Client {
static myUserProfile?: User
static socket?: Socket
static events: { [key: string]: (data: UnknownObject) => UnknownObject } = {}
static connect() {
@@ -27,15 +29,25 @@ class Client {
if (this.socket == null) throw new Error("客戶端未與伺服器端建立連接!")
return new Promise((resolve, reject) => {
this.socket!.timeout(timeout).emit("The_White_Silk", method, args, (err: string, res: ApiCallbackMessage) => {
if (err) return reject(err)
if (err) return reject(err)
resolve(res)
})
})
}
static async auth(token: string, timeout: number = 5000) {
const re = await this.invoke("User.auth", {
access_token: token
}, timeout)
if (re.code == 200)
this.myUserProfile = (await Client.invoke("User.getMyInfo", {
token: token
})).data as unknown as User
return re
}
static on(eventName: ClientEvent, func: (data: UnknownObject) => UnknownObject) {
this.events[eventName] = func
}
static off(eventName: ClientEvent){
static off(eventName: ClientEvent) {
delete this.events[eventName]
}
}

View File

@@ -1,7 +1,6 @@
export default class User {
declare id: string
declare count: number
declare username: string | null
declare username?: string
declare nickname: string
declare avatar: string | null
declare avatar?: string
}

View File

@@ -51,10 +51,10 @@ html {
/* 防止小尺寸图片模糊*/
* {
image-rendering: crisp-edges;
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
}

View File

@@ -1,25 +1,29 @@
import Client from "../api/Client.ts"
import data from "../Data.ts"
import ChatFragment from "./chat/ChatFragment.jsx"
import LoginDialog from "./dialog/LoginDialog.tsx"
import ContactsListItem from "./main/ContactsListItem.jsx"
import RecentsListItem from "./main/RecentsListItem.jsx"
import useEventListener from './useEventListener.ts'
import User from "../api/client_data/User.ts"
import RecentChat from "../api/client_data/RecentChat.ts"
import Avatar from "./Avatar.tsx"
import * as React from 'react'
import { Button, Dialog, NavigationRail, TextField } from "mdui"
import { Button, ButtonIcon, Dialog, NavigationRail, TextField } from "mdui"
import Split from 'split.js'
import 'mdui/jsx.zh-cn.d.ts'
import { checkApiSuccessOrSncakbar } from "./snackbar.ts";
import RegisterDialog from "./dialog/RegisterDialog.tsx";
import { checkApiSuccessOrSncakbar } from "./snackbar.ts"
import RegisterDialog from "./dialog/RegisterDialog.tsx"
import LoginDialog from "./dialog/LoginDialog.tsx"
import UserProfileDialog from "./dialog/UserProfileDialog.tsx"
declare global {
namespace React {
namespace JSX {
interface IntrinsicAttributes {
id?: string
slot?: string
}
}
}
@@ -57,7 +61,7 @@ export default function App() {
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
const navigationRailRef: React.MutableRefObject<NavigationRail | null> = React.useRef(null)
useEventListener(navigationRailRef as React.MutableRefObject<NavigationRail>, 'change', (event) => {
useEventListener(navigationRailRef, 'change', (event) => {
setNavigationItemSelected((event.target as HTMLElement as NavigationRail).value as string)
})
@@ -70,6 +74,14 @@ export default function App() {
const registerInputNickNameRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const registerInputPasswordRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const userProfileDialogRef: React.MutableRefObject<Dialog | null> = React.useRef(null)
const openMyUserProfileDialogButtonRef: React.MutableRefObject<HTMLElement | null> = React.useRef(null)
useEventListener(openMyUserProfileDialogButtonRef, 'click', (_event) => {
userProfileDialogRef.current!.open = true
})
const [myUserProfileCache, setMyUserProfileCache]: [User, React.Dispatch<React.SetStateAction<User>>] = React.useState(null as unknown as User)
React.useEffect(() => {
; (async () => {
Split(['#SideBar', '#ChatFragment'], {
@@ -79,13 +91,14 @@ export default function App() {
})
Client.connect()
const re = await Client.invoke("User.auth", {
access_token: data.access_token || '',
})
const re = await Client.auth(data.access_token || "")
if (re.code == 401)
loginDialogRef.current!.open = true
else if (re.code != 200)
else if (re.code != 200) {
if (checkApiSuccessOrSncakbar(re, "驗證失敗")) return
} else if (re.code == 200) {
setMyUserProfileCache(Client.myUserProfile as User)
}
})()
}, [])
@@ -110,8 +123,14 @@ export default function App() {
loginInputAccountRef={loginInputAccountRef}
loginInputPasswordRef={loginInputPasswordRef} />
<UserProfileDialog
userProfileDialogRef={userProfileDialogRef}
user={myUserProfileCache} />
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
<mdui-button-icon icon="menu" slot="top"></mdui-button-icon>
<mdui-button-icon slot="top">
<Avatar src={myUserProfileCache?.avatar} text={myUserProfileCache?.nickname} avatarRef={openMyUserProfileDialogButtonRef} />
</mdui-button-icon>
<mdui-navigation-rail-item icon="watch_later--outlined" value="Recents"></mdui-navigation-rail-item>
<mdui-navigation-rail-item icon="contacts--outlined" value="Contacts"></mdui-navigation-rail-item>

View File

@@ -1,15 +0,0 @@
export default function Avatar({ src, text, icon = 'person', ...props } = {}) {
return (
src ? <mdui-avatar {...props}>
<img src={src} alt={'(头像)' + text || ''} />
</mdui-avatar>
: (
text ? <mdui-avatar {...props}>
{
text.substring(0, 0)
}
</mdui-avatar>
: <mdui-avatar icon={icon} {...props} />
)
)
}

25
client/ui/Avatar.tsx Normal file
View File

@@ -0,0 +1,25 @@
interface Args extends React.HTMLAttributes<HTMLElement> {
src?: string
text?: string
icon?: string
avatarRef?: React.LegacyRef<HTMLElement>
}
export default function Avatar({
src,
text,
icon = 'person',
avatarRef,
...props
}: Args) {
if (src != null)
return <mdui-avatar ref={avatarRef} {...props} src={src} />
else if (text != null)
return <mdui-avatar ref={avatarRef} {...props}>
{
text.substring(0, 0)
}
</mdui-avatar>
else
return <mdui-avatar icon={icon} ref={avatarRef} {...props} />
}

View File

@@ -1,4 +1,4 @@
import Avatar from "../Avatar.jsx"
import Avatar from "../Avatar.tsx"
/**
* 一条消息

View File

@@ -0,0 +1,80 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui"
import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import Client from "../../api/Client.ts"
import * as CryptoJS from 'crypto-js'
import data from "../../Data.ts"
import Avatar from "../Avatar.tsx"
import User from "../../api/client_data/User.ts"
interface Refs {
userProfileDialogRef: React.MutableRefObject<Dialog | null>
user: User
}
export default function UserProfileDialog({
userProfileDialogRef,
user
}: Refs) {
const isMySelf = Client.myUserProfile?.id == user?.id
const editAvatarButtonRef: React.MutableRefObject<HTMLElement | null> = React.useRef(null)
const chooseAvatarFileRef: React.MutableRefObject<HTMLInputElement | null> = React.useRef(null)
useEventListener(editAvatarButtonRef, 'click', () => chooseAvatarFileRef.current!.click())
useEventListener(chooseAvatarFileRef, 'change', async (_e) => {
const file = chooseAvatarFileRef.current!.files?.[0] as File
if (file == null) return
const re = await Client.invoke("User.setAvatar", {
token: data.access_token,
avatar: file
})
if (checkApiSuccessOrSncakbar(re, "修改失敗")) return
snackbar({
message: "修改成功 (刷新頁面以更新)"
})
})
return (
<mdui-dialog close-on-overlay-click close-on-esc ref={userProfileDialogRef}>
<div style={{
display: "none"
}}>
<input type="file" name="選擇頭像" ref={chooseAvatarFileRef}
accept="image/*" />
</div>
<div style={{
display: 'flex',
alignItems: 'center',
}}>
<Avatar src={user?.avatar} text={user?.nickname} avatarRef={editAvatarButtonRef} style={{
width: '50px',
height: '50px',
}} />
<span style={{
marginLeft: "10px",
fontSize: '15.5px',
}}>{user?.nickname}</span>
</div>
<mdui-divider style={{
marginTop: "10px",
marginBottom: "10px",
}}></mdui-divider>
<mdui-list>
{!isMySelf && <mdui-list-item icon="edit" rounded></mdui-list-item>}
{
isMySelf && <>
<mdui-list-item icon="edit" rounded></mdui-list-item>
<mdui-list-item icon="settings" rounded></mdui-list-item>
<mdui-list-item icon="lock" rounded></mdui-list-item>
</>
}
</mdui-list>
</mdui-dialog>
)
}

View File

@@ -1,4 +1,4 @@
import Avatar from "../Avatar.jsx"
import Avatar from "../Avatar.tsx"
export default function ContactsListItem({ nickName, avatar }) {
return (

View File

@@ -1,4 +1,4 @@
import Avatar from "../Avatar.jsx"
import Avatar from "../Avatar.tsx"
export default function RecentsListItem({ nickName, avatar, content }) {
return (

View File

@@ -8,7 +8,7 @@ import config from '../server/config.ts'
export default defineConfig({
plugins: [deno(), react()],
build: {
sourcemap: "inline",
sourcemap: true,
outDir: "." + config.data_path + '/page_compiled',
},
})

View File

@@ -3,7 +3,10 @@ export type CallMethod =
"User.register" |
"User.login" |
"User.setNickName" |
"User.setUserName" |
"User.setAvatar" |
"User.getMyInfo" |
"Chat.sendMessage" |
"Chat.getMessageHistory"

View File

@@ -92,6 +92,11 @@ export default class UserApi extends BaseApi {
},
}
})
/*
* ================================================
* 個人資料
* ================================================
*/
// 更新頭像
this.registerEvent("User.setAvatar", (args) => {
if (this.checkArgsMissing(args, ['avatar', 'token'])) return {
@@ -117,5 +122,79 @@ export default class UserApi extends BaseApi {
code: 200,
}
})
// 更新昵稱
this.registerEvent("User.setNickName", (args) => {
if (this.checkArgsMissing(args, ['nickname', 'token'])) return {
msg: "參數缺失",
code: 400,
}
const token = TokenManager.decode(args.token as string)
if (!this.checkToken(token)) return {
code: 401,
msg: "令牌無效",
}
const user = User.findById(token.author)
user!.setNickName(args.nickname as string)
return {
msg: "成功",
code: 200,
}
})
// 更新用戶名
this.registerEvent("User.setUserName", (args) => {
if (this.checkArgsMissing(args, ['username', 'token'])) return {
msg: "參數缺失",
code: 400,
}
const token = TokenManager.decode(args.token as string)
if (!this.checkToken(token)) return {
code: 401,
msg: "令牌無效",
}
const user = User.findById(token.author)
user!.setUserName(args.username as string)
return {
msg: "成功",
code: 200,
}
})
// 獲取用戶信息
this.registerEvent("User.getMyInfo", (args) => {
if (this.checkArgsMissing(args, ['token'])) return {
msg: "參數缺失",
code: 400,
}
const token = TokenManager.decode(args.token as string)
if (!this.checkToken(token)) return {
code: 401,
msg: "令牌無效",
}
const user = User.findById(token.author)
return {
msg: "成功",
code: 200,
data: {
username: user!.getUserName(),
nickname: user!.getNickName(),
avatar: user!.getAvatarFileHash() ? "uploaded_files/" + user!.getAvatarFileHash() : null,
id: token.author,
}
}
})
/*
* ================================================
* 公開資料
* ================================================
*/
}
}

View File

@@ -2,6 +2,7 @@ import { DatabaseSync, SQLInputValue } from "node:sqlite"
import { Buffer } from "node:buffer"
import path from 'node:path'
import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import fs_sync from 'node:fs'
import chalk from "chalk"
import { fileTypeFromBuffer } from 'file-type'
@@ -39,7 +40,7 @@ class File {
const hash = this.bean.hash
return path.join(
config.data_path,
"files",
"uploaded_files",
hash.substring(0, 1),
hash.substring(2, 3),
hash.substring(3, 4),
@@ -88,20 +89,21 @@ export default class FileManager {
static async uploadFile(fileName: string, data: Buffer, chatId?: string) {
const hash = crypto.createHash('sha256').update(data).digest('hex')
try {
return FileManager.findByHash(hash)
} catch (_e) {
// Do nothing...
}
const file = FileManager.findByHash(hash)
if (file) return file
const mime = (await fileTypeFromBuffer(data))?.mime || 'application/octet-stream'
fs_sync.writeFileSync(
const folder = path.join(
config.data_path,
"uploaded_files",
hash.substring(0, 1),
hash.substring(2, 3),
hash.substring(3, 4)
)
await fs.mkdir(folder, { recursive: true })
await fs.writeFile(
path.join(
config.data_path,
"files",
hash.substring(0, 1),
hash.substring(2, 3),
hash.substring(3, 4),
folder,
hash
),
data
@@ -131,10 +133,10 @@ export default class FileManager {
private static findAllBeansByCondition(condition: string, ...args: SQLInputValue[]): FileBean[] {
return FileManager.database.prepare(`SELECT * FROM ${FileManager.table_name} WHERE ${condition};`).all(...args) as unknown as FileBean[]
}
static findByHash(hash: string): File {
static findByHash(hash: string): File | null {
const beans = FileManager.findAllBeansByCondition('hash = ?', hash)
if (beans.length == 0)
throw new Error(`找不到 hash 为 ${hash} 的文件`)
return null
else if (beans.length > 1)
console.error(chalk.red(`警告: 查询 hash = ${hash} 时, 查询到多个相同 Hash 的文件`))
return new FileManager.File(beans[0])

View File

@@ -124,8 +124,8 @@ export default class User {
setPassword(password: string) {
this.setAttr("password", password)
}
getAvatar(): Buffer | null {
return this.bean.avatar_file_hash != null ? FileManager.findByHash(this.bean.avatar_file_hash)?.readSync() : null
getAvatarFileHash() {
return this.bean.avatar_file_hash
}
async setAvatar(avatar: Buffer) {
this.setAttr("avatar_file_hash", (await FileManager.uploadFile(`avatar_user_${this.bean.count}`, avatar)).getHash())

View File

@@ -10,6 +10,8 @@ import readline from 'node:readline'
import process from "node:process"
import chalk from "chalk"
import child_process from "node:child_process"
import FileManager from "./data/FileManager.ts"
import path from "node:path"
const app = express()
app.use((req, res, next) => {
@@ -19,6 +21,23 @@ app.use((req, res, next) => {
next()
})
app.use('/', express.static(config.data_path + '/page_compiled'))
app.use('/uploaded_files/:hash', (req, res) => {
const hash = req.params.hash as string
if (hash == null) {
res.sendStatus(404)
res.send("404 Not Found")
return
}
const file = FileManager.findByHash(hash)
if (file == null) {
res.sendStatus(404)
res.send("404 Not Found")
return
}
res.setHeader('Content-Type', file!.getMime())
res.sendFile(path.resolve(file!.getFilePath()))
})
const httpServer: HttpServerLike = (
((config.server.use == 'http') && http.createServer(app)) ||