ci: 调用 platform/workflow 公共工作流并精简注释
All checks were successful
CI / build (push) Successful in 2m17s
All checks were successful
CI / build (push) Successful in 2m17s
ui: 首页对齐 DeepSeek 式布局(侧栏、消息气泡、思考折叠、输入区胶囊与发送按钮) Made-with: Cursor
This commit is contained in:
@@ -1,9 +1,4 @@
|
|||||||
# 公共工作流仓库:platform/workflow
|
# 业务 CI:触发公共工作流;
|
||||||
# Gitea 要求 uses 格式:{owner}/{repo}/.gitea/workflows/{文件名}@{ref}
|
|
||||||
# 公共仓内请将工作流放在:.gitea/workflows/web-spa-deploy.yml(若当前仅在 ci/ 下,请复制或移动到该路径)
|
|
||||||
#
|
|
||||||
# 本仓库「工作流 → 变量」:DEPLOY_PATH、DEPLOY_HOST、DEPLOY_USER
|
|
||||||
# 本仓库「工作流 → 密钥」:SSH_PRIVATE_KEY(secrets: inherit)
|
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -21,7 +16,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
uses: platform/workflow/.gitea/workflows/web-spa-deploy.yml@main
|
uses: platform/workflow/.gitea/workflows/web-spa-deploy.yml@main
|
||||||
with:
|
with:
|
||||||
node_version: "22.14.0"
|
node_version: "22.14.0" # Node 完整 semver
|
||||||
yarn_version: "1.22.22"
|
yarn_version: "1.22.22" # Yarn 1
|
||||||
project_dir: "chat-one-web"
|
project_dir: "chat-one-web" # 远端目录 = $DEPLOY_PATH/本字段
|
||||||
secrets: inherit
|
secrets: inherit # 传入 SSH_PRIVATE_KEY
|
||||||
|
|||||||
@@ -6,3 +6,16 @@ body,
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DeepSeek 风格近似色 */
|
||||||
|
:root {
|
||||||
|
--ds-bg-main: #ffffff;
|
||||||
|
--ds-bg-sider: #f7f7f8;
|
||||||
|
--ds-border: #ececec;
|
||||||
|
--ds-text-secondary: #8b8b8b;
|
||||||
|
--ds-user-bubble: #e8f3ff;
|
||||||
|
--ds-user-border: #cce7ff;
|
||||||
|
--ds-active-item: #e3f2fd;
|
||||||
|
--ds-send: #4dabf7;
|
||||||
|
--ds-send-hover: #339af0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,62 +1,62 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Drawer, Input, Layout, Space, Typography } from "antd";
|
|
||||||
import {
|
import {
|
||||||
|
ArrowUpOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
|
PaperClipOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
SearchOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
import { Button, Collapse, Drawer, Input, Layout, Typography } from "antd";
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
const MOBILE_WIDTH = 750;
|
const MOBILE_WIDTH = 750;
|
||||||
|
|
||||||
const mockMessages = [
|
const mockMessages = [
|
||||||
{ role: "assistant", content: "你好,我是 ChatOne 助手,有什么可以帮你?" },
|
{ role: "assistant" as const, content: "你好,我是 ChatOne 助手,有什么可以帮你?" },
|
||||||
{ role: "user", content: "请帮我总结今天的工作内容。" },
|
{ role: "user" as const, content: "请帮我总结今天的工作内容。" },
|
||||||
{ role: "assistant", content: "当然可以,请先告诉我今天完成了哪些任务。" },
|
{
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "当然可以,请先告诉我今天完成了哪些任务。",
|
||||||
|
thinking: "正在理解用户意图并检索相关知识…",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
// 桌面端侧边栏折叠状态(移动端不使用该状态)
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
// 移动端抽屉侧边栏显示状态
|
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
// 当前视口宽度(用于调试响应式是否生效)
|
|
||||||
const [viewportWidth, setViewportWidth] = useState(0);
|
const [viewportWidth, setViewportWidth] = useState(0);
|
||||||
// 是否为移动端(小于 750px)
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [deepThink, setDeepThink] = useState(false);
|
||||||
|
const [smartSearch, setSmartSearch] = useState(false);
|
||||||
|
|
||||||
const menuItems = useMemo(
|
const historyGroups = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ icon: <MessageOutlined />, label: "新建会话" },
|
{ title: "30 天内", keys: ["前端 CI 工具对比", "Nginx SPA 配置"] },
|
||||||
{ icon: <UserOutlined />, label: "我的对话" },
|
{ title: "2025-12", keys: ["Gitea Actions 入门"] },
|
||||||
{ icon: <SettingOutlined />, label: "设置" },
|
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateIsMobile = () => {
|
const updateIsMobile = () => {
|
||||||
// 兼容不同浏览器/缩放场景下的视口宽度读取
|
const w = Math.min(
|
||||||
const viewportWidth = Math.min(
|
|
||||||
window.innerWidth,
|
window.innerWidth,
|
||||||
document.documentElement.clientWidth || window.innerWidth,
|
document.documentElement.clientWidth || window.innerWidth,
|
||||||
);
|
);
|
||||||
setViewportWidth(viewportWidth);
|
setViewportWidth(w);
|
||||||
const mobile = viewportWidth < MOBILE_WIDTH;
|
const mobile = w < MOBILE_WIDTH;
|
||||||
setIsMobile(mobile);
|
setIsMobile(mobile);
|
||||||
if (!mobile) {
|
if (!mobile) setMobileSidebarOpen(false);
|
||||||
// 切回桌面端时关闭移动抽屉,避免状态残留
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
updateIsMobile();
|
updateIsMobile();
|
||||||
window.addEventListener("resize", updateIsMobile);
|
window.addEventListener("resize", updateIsMobile);
|
||||||
window.addEventListener("orientationchange", updateIsMobile);
|
window.addEventListener("orientationchange", updateIsMobile);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", updateIsMobile);
|
window.removeEventListener("resize", updateIsMobile);
|
||||||
window.removeEventListener("orientationchange", updateIsMobile);
|
window.removeEventListener("orientationchange", updateIsMobile);
|
||||||
@@ -64,106 +64,241 @@ export default function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
<>
|
<div className="flex h-full flex-col bg-[var(--ds-bg-sider)]">
|
||||||
{/* 侧边栏头部:桌面折叠时显示缩写,其他情况显示完整标题 */}
|
<div className="shrink-0 px-3 pt-3 pb-2">
|
||||||
<div className="h-14 border-b border-gray-100 px-4 flex items-center">
|
<Button
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
block
|
||||||
{collapsed && !isMobile ? "CO" : "ChatOne"}
|
className="h-10! rounded-xl! border-[var(--ds-border)]! bg-[var(--ds-bg-main)]! text-[13px]! font-medium shadow-none hover:border-sky-300! hover:text-sky-600!"
|
||||||
</Typography.Title>
|
icon={<PlusOutlined />}
|
||||||
|
>
|
||||||
|
{(!collapsed || isMobile) && "新建会话"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 py-3">
|
|
||||||
<Space orientation="vertical" className="w-full">
|
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
|
||||||
{menuItems.map((item) => (
|
{historyGroups.map((group) => (
|
||||||
<Button key={item.label} type="text" className="w-full text-left!">
|
<div key={group.title} className="mb-4">
|
||||||
<Space>
|
<Typography.Text
|
||||||
{item.icon}
|
className="mb-2 block px-2 text-[11px] uppercase tracking-wide"
|
||||||
{/* 桌面折叠时隐藏文字;移动端抽屉内始终显示文字 */}
|
style={{ color: "var(--ds-text-secondary)" }}
|
||||||
{(!collapsed || isMobile) && <span>{item.label}</span>}
|
>
|
||||||
</Space>
|
{group.title}
|
||||||
</Button>
|
</Typography.Text>
|
||||||
))}
|
{group.keys.map((key) => (
|
||||||
</Space>
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className="mb-1 flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-[13px] text-neutral-700 transition-colors hover:bg-black/5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
key === historyGroups[0].keys[0] ? "var(--ds-active-item)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageOutlined className="shrink-0 text-neutral-400" />
|
||||||
|
{(!collapsed || isMobile) && <span className="truncate">{key}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
<div className="shrink-0 border-t border-[var(--ds-border)] p-3">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="h-10 w-full justify-start! rounded-xl! px-2 text-neutral-600 hover:bg-black/5!"
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
>
|
||||||
|
{(!collapsed || isMobile) && "设置"}
|
||||||
|
</Button>
|
||||||
|
<div className="mt-2 flex items-center gap-2 rounded-xl px-2 py-1.5">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-500">
|
||||||
|
<UserOutlined />
|
||||||
|
</div>
|
||||||
|
{(!collapsed || isMobile) && (
|
||||||
|
<Typography.Text className="truncate text-[13px]">用户</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: "100vh" }}>
|
<Layout style={{ minHeight: "100vh", background: "var(--ds-bg-main)" }}>
|
||||||
{/* 桌面端固定侧边栏;移动端改为 Drawer 抽屉 */}
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Sider trigger={null} collapsible collapsed={collapsed} theme="light">
|
<Sider
|
||||||
|
trigger={null}
|
||||||
|
collapsible
|
||||||
|
collapsed={collapsed}
|
||||||
|
width={260}
|
||||||
|
collapsedWidth={72}
|
||||||
|
theme="light"
|
||||||
|
className="!bg-[var(--ds-bg-sider)] !border-r !border-[var(--ds-border)]"
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-center border-b border-[var(--ds-border)] px-3">
|
||||||
|
<Typography.Title level={5} className="!m-0 !text-[15px] !font-semibold">
|
||||||
|
{collapsed ? "CO" : "ChatOne"}
|
||||||
|
</Typography.Title>
|
||||||
|
</div>
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</Sider>
|
</Sider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Layout>
|
<Layout style={{ background: "var(--ds-bg-main)" }}>
|
||||||
<Header className="bg-white! px-4! border-b! border-gray-100! flex items-center justify-between">
|
<Header
|
||||||
<Button
|
className="!flex !h-14 !items-center !justify-between !border-b !border-[var(--ds-border)] !bg-[var(--ds-bg-main)] !px-5"
|
||||||
type="text"
|
style={{ lineHeight: "56px" }}
|
||||||
icon={
|
>
|
||||||
isMobile ? (
|
<div className="flex items-center gap-3">
|
||||||
<MenuUnfoldOutlined />
|
<Button
|
||||||
) : collapsed ? (
|
type="text"
|
||||||
<MenuUnfoldOutlined />
|
className="text-neutral-500 hover:bg-black/5!"
|
||||||
) : (
|
icon={
|
||||||
<MenuFoldOutlined />
|
isMobile ? (
|
||||||
)
|
<MenuUnfoldOutlined />
|
||||||
}
|
) : collapsed ? (
|
||||||
onClick={() => {
|
<MenuUnfoldOutlined />
|
||||||
if (isMobile) {
|
) : (
|
||||||
// 移动端点击按钮:打开左侧抽屉
|
<MenuFoldOutlined />
|
||||||
setMobileSidebarOpen(true);
|
)
|
||||||
} else {
|
|
||||||
// 桌面端点击按钮:切换侧边栏折叠
|
|
||||||
setCollapsed((v) => !v);
|
|
||||||
}
|
}
|
||||||
}}
|
onClick={() => {
|
||||||
/>
|
if (isMobile) setMobileSidebarOpen(true);
|
||||||
<Space size={12}>
|
else setCollapsed((v) => !v);
|
||||||
<Typography.Text type="secondary">ChatOne Web</Typography.Text>
|
}}
|
||||||
<Typography.Text type="secondary">
|
/>
|
||||||
{`W:${viewportWidth}px / ${isMobile ? "mobile" : "desktop"}`}
|
<Typography.Text className="text-[15px] font-medium text-neutral-800">
|
||||||
|
国内前端主流 CI 工具
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</div>
|
||||||
|
<span className="rounded-full border border-[var(--ds-border)] bg-neutral-50 px-3 py-1 text-xs text-neutral-500">
|
||||||
|
快速模式
|
||||||
|
</span>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Content className="bg-gray-50 p-6">
|
<Content
|
||||||
{/* 聊天窗口:消息列表 + 底部输入区 */}
|
className="flex flex-col !bg-[var(--ds-bg-main)]"
|
||||||
<div className="h-[calc(100vh-112px)] rounded-lg bg-white border border-gray-100 flex flex-col">
|
style={{ minHeight: "calc(100vh - 56px)" }}
|
||||||
<div className="border-b border-gray-100 px-5 py-3">
|
>
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
聊天窗口
|
<div className="min-h-0 flex-1 overflow-auto px-4 py-6 md:px-8">
|
||||||
</Typography.Title>
|
<div className="mx-auto flex max-w-3xl flex-col gap-5">
|
||||||
|
{mockMessages.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex ${item.role === "user" ? "justify-end" : "justify-start"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
item.role === "user"
|
||||||
|
? "max-w-[85%] rounded-2xl border px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800"
|
||||||
|
: "max-w-[85%] space-y-2"
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
item.role === "user"
|
||||||
|
? {
|
||||||
|
background: "var(--ds-user-bubble)",
|
||||||
|
borderColor: "var(--ds-user-border)",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.role === "assistant" && item.thinking && (
|
||||||
|
<Collapse
|
||||||
|
bordered={false}
|
||||||
|
size="small"
|
||||||
|
className="!mb-2 !rounded-xl !bg-neutral-50 !border !border-[var(--ds-border)]"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "think",
|
||||||
|
label: (
|
||||||
|
<span className="text-[13px] text-neutral-600">
|
||||||
|
<ThunderboltOutlined className="mr-1.5" />
|
||||||
|
已思考(用时约 2 秒)
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<Typography.Text type="secondary" className="text-[13px]">
|
||||||
|
{item.thinking}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.role === "assistant" && (
|
||||||
|
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/80 px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800">
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.role === "user" && item.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto px-5 py-4 flex flex-col gap-2">
|
<div className="shrink-0 border-t border-[var(--ds-border)] bg-[var(--ds-bg-main)] px-4 py-4 md:px-8">
|
||||||
{mockMessages.map((item, index) => (
|
<div className="mx-auto max-w-3xl">
|
||||||
<div
|
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
|
||||||
key={index}
|
<Input.TextArea
|
||||||
className={`flex ${item.role === "user" ? "justify-end" : "justify-start"}`}
|
value={inputValue}
|
||||||
>
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
<div
|
placeholder="给 ChatOne 发送消息"
|
||||||
className={`max-w-[80%] rounded-lg px-3 py-2 ${
|
variant="borderless"
|
||||||
item.role === "user"
|
autoSize={{ minRows: 1, maxRows: 6 }}
|
||||||
? "bg-blue-50 border border-blue-100"
|
className="!px-1 !text-[15px] placeholder:text-neutral-400"
|
||||||
: "bg-gray-50 border border-gray-100"
|
/>
|
||||||
}`}
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Typography.Text>{item.content}</Typography.Text>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeepThink((v) => !v)}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
deepThink
|
||||||
|
? "border-sky-300 bg-sky-50 text-sky-700"
|
||||||
|
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ThunderboltOutlined />
|
||||||
|
深度思考
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSmartSearch((v) => !v)}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
smartSearch
|
||||||
|
? "border-sky-300 bg-sky-50 text-sky-700"
|
||||||
|
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SearchOutlined />
|
||||||
|
智能搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PaperClipOutlined className="text-neutral-400" />}
|
||||||
|
className="text-neutral-400"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<ArrowUpOutlined />}
|
||||||
|
className="!flex !h-9 !w-9 !items-center !justify-center !border-0 !shadow-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--ds-send)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{import.meta.env.DEV && (
|
||||||
</div>
|
<Typography.Text type="secondary" className="mt-2 block text-center text-[11px]">
|
||||||
|
W:{viewportWidth}px · {isMobile ? "mobile" : "desktop"}
|
||||||
<div className="border-t border-gray-100 p-4">
|
</Typography.Text>
|
||||||
<Space.Compact className="w-full">
|
)}
|
||||||
<Input
|
</div>
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
|
||||||
placeholder="输入消息..."
|
|
||||||
/>
|
|
||||||
<Button type="primary">发送消息</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
@@ -175,7 +310,8 @@ export default function HomePage() {
|
|||||||
closable
|
closable
|
||||||
onClose={() => setMobileSidebarOpen(false)}
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
open={isMobile && mobileSidebarOpen}
|
open={isMobile && mobileSidebarOpen}
|
||||||
size={260}
|
width={280}
|
||||||
|
styles={{ body: { padding: 0, background: "var(--ds-bg-sider)" } }}
|
||||||
>
|
>
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
Reference in New Issue
Block a user