Compare commits

...

11 Commits

Author SHA1 Message Date
CrescentLeaf
32369ec3bd fix: allow 'declare' modifier while compiling 2025-09-06 16:43:05 +08:00
CrescentLeaf
2d92fffb55 feat(wip): User & RecentChat in client 2025-09-06 16:40:49 +08:00
CrescentLeaf
e8f97d9131 ui: 微調輸入框的邊距 2025-09-06 16:40:32 +08:00
CrescentLeaf
8a1ff9ac23 chore: 使用 TS 重構部分 React 組件, 引入 Mdui 部分組件的類型定義 2025-09-06 16:40:17 +08:00
CrescentLeaf
7553c5b281 chore: apply rename: types -> typedef 2025-09-06 16:37:53 +08:00
CrescentLeaf
7c616a2dac chore: rename types -> typedef 2025-09-06 16:37:22 +08:00
CrescentLeaf
28a8eaf337 ui: 添加了面板間自由分割的支持 2025-09-06 14:52:34 +08:00
CrescentLeaf
60fcb19769 chore: 修改調試配置文件 2025-09-06 14:34:30 +08:00
CrescentLeaf
e50a90a770 feat(wip): 進一步完善 UserAPI 和基類函數 2025-09-06 14:34:14 +08:00
CrescentLeaf
adbe6b193b chore: 靜態儲存 Material Icons 2025-09-06 14:33:31 +08:00
CrescentLeaf
c5d1f11017 chore: 通過 ESM 引入外部脚本
* React, ReactDOM 和 CryptoES
* 集中在 Imports.ts 中
* 向每一個 JSX 添加 React 的 import
2025-09-06 14:32:56 +08:00
31 changed files with 3679 additions and 187 deletions

4
.vscode/launch.json vendored
View File

@@ -5,8 +5,8 @@
"version": "0.2.0",
"configurations": [
{
"command": "deno task main",
"name": "Run deno task main",
"command": "deno task debug",
"name": "Run deno task debug",
"request": "launch",
"type": "node-terminal",
},

View File

@@ -1,8 +1,10 @@
import { CryptoES } from './Imports.ts'
const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == ''
const aes = {
enc: (m: string, k: string) => CryptoJS.AES.encrypt(m, k).toString(),
dec: (m: string, k: string) => CryptoJS.AES.decrypt(m, k).toString(CryptoJS.enc.Utf8),
enc: (m: string, k: string) => CryptoES.AES.encrypt(m, k).toString(CryptoES.enc.Utf8),
dec: (m: string, k: string) => CryptoES.AES.decrypt(m, k).toString(CryptoES.enc.Utf8),
}
const key = location.host + '_TWS_姐姐'

29
client/Imports.ts Normal file
View File

@@ -0,0 +1,29 @@
import * as React from 'https://esm.sh/react@18.3.1'
import * as ReactDOM from 'https://esm.sh/react-dom@18.3.1'
import CryptoES from 'https://unpkg.com/crypto-es@3.0.4/dist/index.mjs'
import type { Dialog } from 'https://unpkg.com/mdui@2.1.4/components/dialog/index.d.ts'
import type { TextField } from 'https://unpkg.com/mdui@2.1.4/components/text-field/index.d.ts'
import type { Button } from 'https://unpkg.com/mdui@2.1.4/components/button/index.d.ts'
import type { NavigationRail } from 'https://unpkg.com/mdui@2.1.4/components/navigation-rail/navigation-rail.d.ts'
declare global {
namespace React {
namespace JSX {
interface IntrinsicAttributes {
id?: string
}
}
}
}
export {
React,
ReactDOM,
CryptoES,
Dialog as MduiDialog,
TextField as MduiTextField,
Button as MduiButton,
NavigationRail as MduiNavigationRail,
}

View File

@@ -0,0 +1,6 @@
export default class RecentChat {
declare id: string
declare title: string
declare avatar: string | null
declare content: string
}

View File

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

View File

@@ -11,14 +11,10 @@
</script>
<link rel="icon" href="icon.ico" />
<link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet" />
<script src="https://unpkg.com/split.js/dist/split.min.js"></script>
<link rel="stylesheet" href="./static/material_icons.css" />
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/split.js@1.6.5/dist/split.min.js"></script>
<title>TheWhiteSilk</title>
@@ -82,6 +78,16 @@
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
}
.gutter {
background-color: rgb(var(--mdui-color-surface-variant));;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-horizontal {
cursor: col-resize;
}
</style>
</head>
@@ -93,7 +99,8 @@
alert('很抱歉, 此应用无法在较旧的浏览器运行, 请使用基于 Chromium 89+ 的浏览器(内核)使用 :(')
</script>
<script type="module">
import App from './ui/App.jsx'
import App from './ui/App.tsx'
import { React, ReactDOM } from './Imports.ts'
ReactDOM.createRoot(document.getElementById('app')).render(React.createElement(App, null))
let onResize = () => {
@@ -103,6 +110,14 @@
}
window.addEventListener('resize', onResize)
onResize()
window.addEventListener('load', () => {
Split(['#SideBar', '#ChatFragment'], {
sizes: [25, 75],
minSize: [200, 400],
gutterSize: 2,
})
})
</script>
</body>

View File

@@ -0,0 +1,45 @@
/* fallback */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(material_icons.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
}
/* fallback */
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-weight: 400;
src: url(material_icons_outlined.woff2) format('woff2');
}
.material-icons-outlined {
font-family: 'Material Icons Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
}

Binary file not shown.

Binary file not shown.

3350
client/typedef/mdui-jsx.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +0,0 @@
import Client from "../api/Client.ts";
import data from "../Data.ts";
import ChatFragment from "./chat/ChatFragment.jsx"
import LoginDialog from "./dialog/LoginDialog.jsx"
import ContactsListItem from "./main/ContactsListItem.jsx"
import RecentsListItem from "./main/RecentsListItem.jsx"
import snackbar from "./snackbar.js";
import useEventListener from './useEventListener.js'
export default function App() {
const [recentsList, setRecentsList] = React.useState([
/* {
userId: 0,
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickName: "麻油衣酱",
content: "成步堂君, 我又坐牢了("
},
{
userId: 0,
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickName: "Maya Fey",
content: "我是绫里真宵, 是一名灵媒师~"
}, */
])
const [contactsMap, setContactsMap] = React.useState({
所有: [
/* {
userId: 0,
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickName: "麻油衣酱",
},
{
userId: 0,
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickName: "Maya Fey",
}, */
],
})
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
const navigationRailRef = React.useRef(null)
useEventListener(navigationRailRef, 'change', (event) => {
setNavigationItemSelected(event.target.value)
})
const [
loginDialogRef,
inputAccountRef,
inputPasswordRef,
registerButtonRef,
loginButtonRef
] = [React.useRef(null), React.useRef(null), React.useRef(null), React.useRef(null), React.useRef(null)]
React.useEffect(async () => {
Client.connect()
const re = await Client.invoke("User.auth", {
access_token: data.access_token,
})
if (re.code == 401)
loginDialogRef.current.show()
else if (re.code != 200)
snackbar("驗證失敗: " + re.msg)
})
return (
<div style={{
display: "flex",
position: 'relative',
width: 'calc(var(--whitesilk-window-width) - 80px)',
height: 'var(--whitesilk-window-height)',
}}>
<LoginDialog
ref={loginDialogRef}
inputAccountRef={inputAccountRef}
inputPasswordRef={inputPasswordRef}
registerButtonRef={registerButtonRef}
loginButtonRef={loginButtonRef} />
{
// 移动端用 页面调试
// 換個地方弄
// (new URL(location.href).searchParams.get('debug') == 'true') && <script src="https://unpkg.com/eruda/eruda.js"></script>
}
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
<mdui-button-icon icon="menu" slot="top"></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>
<mdui-button-icon icon="settings" slot="bottom"></mdui-button-icon>
</mdui-navigation-rail>
{
// 侧边列表
}
{
// 最近聊天
<mdui-list style={{
width: "35%",
overflowY: 'auto',
paddingRight: '10px',
display: navigationItemSelected == "Recents" ? null : 'none'
}}>
{
recentsList.map((v) =>
<RecentsListItem
key={v.userId}
nickName={v.nickName}
avatar={v.avatar}
content={v.content} />
)
}
</mdui-list>
}
{
// 联系人列表
<mdui-list style={{
width: "35%",
overflowY: 'auto',
paddingRight: '10px',
display: navigationItemSelected == "Contacts" ? null : 'none'
}}>
<mdui-collapse accordion value={Object.keys(contactsMap)[0]}>
{
Object.keys(contactsMap).map((v) =>
<mdui-collapse-item key={v} value={v}>
<mdui-list-subheader slot="header">{v}</mdui-list-subheader>
{
contactsMap[v].map((v2) =>
<ContactsListItem
key={v2.userId}
nickName={v2.nickName}
avatar={v2.avatar} />
)
}
</mdui-collapse-item>
)
}
</mdui-collapse>
</mdui-list>
}
{
// 分割线
}
<div style={{
// 我们删除了 body 的padding 因此不需要再 calc 了
height: 'var(--whitesilk-window-height)',
marginRight: '10px',
}}>
<mdui-divider vertical></mdui-divider>
</div>
{
// 聊天页面
}
<ChatFragment />
</div>
)
}

151
client/ui/App.tsx Normal file
View File

@@ -0,0 +1,151 @@
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 snackbar from "./snackbar.js"
import useEventListener from './useEventListener.js'
import { MduiDialog, React, MduiNavigationRail, MduiTextField, MduiButton } from '../Imports.ts'
import User from "../api/client_data/User.ts"
import RecentChat from "../api/client_data/RecentChat.ts"
export default function App() {
const [recentsList, setRecentsList] = React.useState([
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
title: "麻油衣酱",
content: "成步堂君, 我又坐牢了("
},
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
title: "Maya Fey",
content: "我是绫里真宵, 是一名灵媒师~"
},
] as RecentChat[])
const [contactsMap, setContactsMap] = React.useState({
: [
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "麻油衣酱",
},
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "Maya Fey",
},
],
} as unknown as { [key: string]: User[] })
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
const navigationRailRef = React.useRef(null)
useEventListener(navigationRailRef, 'change', (event) => {
setNavigationItemSelected((event.target as HTMLElement as MduiNavigationRail).value as string)
})
const loginDialogRef: React.MutableRefObject<MduiDialog | null> = React.useRef(null)
const inputAccountRef: React.MutableRefObject<MduiTextField | null> = React.useRef(null)
const inputPasswordRef: React.MutableRefObject<MduiTextField | null> = React.useRef(null)
const registerButtonRef: React.MutableRefObject<MduiButton | null> = React.useRef(null)
const loginButtonRef: React.MutableRefObject<MduiButton | null> = React.useRef(null)
React.useEffect(() => {
// deno-lint-ignore no-window-prefix no-window
window.addEventListener('load', async () => {
Client.connect()
const re = await Client.invoke("User.auth", {
access_token: data.access_token,
})
if (re.code == 401)
loginDialogRef.current!.open = true
else if (re.code != 200)
snackbar("驗證失敗: " + re.msg)
})
}, [])
return (
<div style={{
display: "flex",
position: 'relative',
width: 'calc(var(--whitesilk-window-width) - 80px)',
height: 'var(--whitesilk-window-height)',
}}>
<LoginDialog
loginDialogRef={loginDialogRef}
inputAccountRef={inputAccountRef}
inputPasswordRef={inputPasswordRef}
registerButtonRef={registerButtonRef}
loginButtonRef={loginButtonRef} />
{
// 移动端用 页面调试
// 換個地方弄
// (new URL(location.href).searchParams.get('debug') == 'true') && <script src="https://unpkg.com/eruda/eruda.js"></script>
}
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
<mdui-button-icon icon="menu" slot="top"></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>
<mdui-button-icon icon="settings" slot="bottom"></mdui-button-icon>
</mdui-navigation-rail>
{
// 侧边列表
}
<div id="SideBar">
{
// 最近聊天
<mdui-list style={{
overflowY: 'auto',
paddingRight: '10px',
display: navigationItemSelected == "Recents" ? null : 'none'
}}>
{
recentsList.map((v) =>
<RecentsListItem
key={v.id}
nickName={v.title}
avatar={v.avatar}
content={v.content} />
)
}
</mdui-list>
}
{
// 联系人列表
<mdui-list style={{
overflowY: 'auto',
paddingRight: '10px',
display: navigationItemSelected == "Contacts" ? null : 'none'
}}>
<mdui-collapse accordion value={Object.keys(contactsMap)[0]}>
{
Object.keys(contactsMap).map((v) =>
<mdui-collapse-item key={v} value={v}>
<mdui-list-subheader slot="header">{v}</mdui-list-subheader>
{
contactsMap[v].map((v2) =>
<ContactsListItem
key={v2.id}
nickName={v2.nickname}
avatar={v2.avatar} />
)
}
</mdui-collapse-item>
)
}
</mdui-collapse>
</mdui-list>
}
</div>
{
// 聊天页面
}
<ChatFragment id="ChatFragment" />
</div>
)
}

View File

@@ -1,3 +1,5 @@
import { React } from '../Imports.ts'
export default function Avatar({ src, text, icon = 'person', ...props } = {}) {
return (
src ? <mdui-avatar {...props}>

View File

@@ -1,7 +1,9 @@
import Message from "./Message.jsx"
import MessageContainer from "./MessageContainer.jsx"
export default function ChatFragment() {
import { React } from '../../Imports.ts'
export default function ChatFragment({ ...props } = {}) {
return (
<div style={{
width: '100%',
@@ -9,7 +11,7 @@ export default function ChatFragment() {
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
}}>
}} {...props}>
<mdui-top-app-bar style={{
position: 'sticky',
}}>
@@ -45,10 +47,12 @@ export default function ChatFragment() {
paddingTop: '0.1rem',
height: '4rem',
position: 'sticky',
bottom: '0',
bottom: '2px',
marginLeft: '5px',
marginRight: '4px',
backgroundColor: 'rgb(var(--mdui-color-background))',
}}>
<mdui-text-field variant="outlined" placeholder="喵呜~" style={{
<mdui-text-field variant="outlined" placeholder="喵呜~" autosize max-rows="1" style={{
marginRight: '10px',
}}></mdui-text-field>
<mdui-button-icon slot="end-icon" icon="more_vert" style={{
@@ -59,6 +63,6 @@ export default function ChatFragment() {
}}></mdui-button-icon>
</div>
</div>
</div>
</div >
)
}

View File

@@ -1,5 +1,7 @@
import Avatar from "../Avatar.jsx"
import { React } from '../../Imports.ts'
/**
* 一条消息
* @param { Object } param

View File

@@ -1,3 +1,5 @@
import { React } from '../../Imports.ts'
/**
* 消息容器
* @returns { React.JSX.Element }

View File

@@ -1,3 +1,5 @@
import { React } from '../../Imports.ts'
/**
* 一条系统提示消息
* @returns { React.JSX.Element }

View File

@@ -1,12 +1,23 @@
import { React, MduiDialog, MduiTextField, MduiButton } from '../../Imports.ts'
import '../../mdui-jsx.d.ts'
interface Refs {
inputAccountRef: React.MutableRefObject<MduiTextField | null>
inputPasswordRef: React.MutableRefObject<MduiTextField | null>
registerButtonRef: React.MutableRefObject<MduiButton | null>
loginButtonRef: React.MutableRefObject<MduiButton | null>
loginDialogRef: React.MutableRefObject<MduiDialog | null>
}
export default function LoginDialog({
inputAccountRef,
inputPasswordRef,
registerButtonRef,
loginButtonRef,
...prop
}) {
loginDialogRef
}: Refs) {
return (
<mdui-dialog headline="登录" {...prop}>
<mdui-dialog headline="登录" ref={loginDialogRef}>
<mdui-text-field label="账号" ref={inputAccountRef}></mdui-text-field>
<div style={{

View File

@@ -1,5 +1,7 @@
import Avatar from "../Avatar.jsx"
import { React } from '../../Imports.ts'
export default function ContactsListItem({ nickName, avatar }) {
return (
<mdui-list-item rounded style={{

View File

@@ -1,5 +1,7 @@
import Avatar from "../Avatar.jsx"
import { React } from '../../Imports.ts'
export default function RecentsListItem({ nickName, avatar, content }) {
return (
<mdui-list-item rounded style={{

View File

@@ -1,3 +1,3 @@
export default function snackbar(text) {
$("#public_snackbar").text(text).get(0).open()
}
}

View File

@@ -3,6 +3,8 @@
* @param { Event } event
*/
import { React } from "../Imports.ts"
/**
* 绑定事件
* @param { React.Ref } ref

View File

@@ -1,6 +1,7 @@
{
"tasks": {
"main": "deno run --allow-read --allow-write --allow-env --allow-net --allow-sys ./server/main.ts",
"debug": "deno run --watch --allow-read --allow-write --allow-env --allow-net --allow-sys ./server/main.ts",
"test": "deno run --allow-read --allow-write --allow-env --allow-net ./server/main_test.ts"
},
"imports": {

View File

@@ -1,8 +1,8 @@
import HttpServerLike from '../types/HttpServerLike.ts'
import HttpServerLike from '../typedef/HttpServerLike.ts'
import UserApi from "./UserApi.ts"
import * as SocketIo from "socket.io"
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
import EventCallbackFunction from "../types/EventCallbackFunction.ts"
import EventCallbackFunction from "../typedef/EventCallbackFunction.ts"
import BaseApi from "./BaseApi.ts"
export default class ApiManager {
@@ -41,6 +41,12 @@ export default class ApiManager {
return callback(this.event_listeners[name]?.(args))
} catch (e) {
console.error(e)
try {
callback({
code: 500,
msg: "錯誤: " + e
})
} catch(_e) {}
}
})
})

View File

@@ -1,4 +1,4 @@
import EventCallbackFunction from "../types/EventCallbackFunction.ts"
import EventCallbackFunction from "../typedef/EventCallbackFunction.ts"
import ApiManager from "./ApiManager.ts"
import { CallMethod } from './ApiDeclare.ts'
@@ -8,6 +8,12 @@ export default abstract class BaseApi {
this.onInit()
}
abstract onInit(): void
checkArgsMissing(args: { [key: string]: unknown }, names: []) {
for (const k of names)
if (!(k in args))
return true
return false
}
registerEvent(name: CallMethod, func: EventCallbackFunction) {
if (!name.startsWith(this.getName() + ".")) throw Error("注冊的事件應該與接口集合命名空間相匹配: " + name)
ApiManager.addEventListener(name, func)

View File

@@ -5,13 +5,10 @@ export default class UserApi extends BaseApi {
return "User"
}
override onInit(): void {
this.registerEvent("User.auth", () => {
this.registerEvent("User.auth", (args) => {
return {
msg: "",
code: 200,
data: {
}
code: 401,
}
})
}

View File

@@ -11,7 +11,11 @@ async function compileJs(path: string) {
},
],
"@babel/preset-react",
"@babel/preset-typescript",
[
"@babel/preset-typescript", {
allowDeclareFields: true,
},
],
],
targets: {
chrome: "53",

View File

@@ -2,7 +2,7 @@ import ApiManager from "./api/ApiManager.ts"
// @ts-types="npm:@types/express"
import express from 'express'
import * as SocketIo from 'socket.io'
import HttpServerLike from "./types/HttpServerLike.ts"
import HttpServerLike from "./typedef/HttpServerLike.ts"
import config from './config.ts'
import http from 'node:http'
import https from 'node:https'

View File

@@ -1,5 +1,5 @@
import ApiCallbackMessage from "../api/ApiCallbackMessage.ts"
type EventCallbackFunction = (args: {}) => ApiCallbackMessage
type EventCallbackFunction = (args: { [key: string]: unknown }) => ApiCallbackMessage
export default EventCallbackFunction