feat: 初始化船长·HeiXiu 桌面应用项目(错误是svg的问题)

Electron + React 19 + TypeScript + Ant Design 6 + Tailwind CSS 4

  【核心架构】
  - Electron 主进程(窗口管理、平台检测、图标适配)
  - Vite 构建链(双产物 dist/ + dist-electron/)
  - 共享层 shared/(类型、常量、工具函数)
  - IPC 通信框架 + 安全守卫

  【主题系统】
  - 蓝紫渐变主题(亮色/暗色双模式)
  - Ant Design ConfigProvider 适配(lightAlgorithm / darkAlgorithm)
  - Tailwind CSS 4 @theme 指令 + CSS 变量
  - 设计令牌三处同步(tokens.ts / globals.css / antd-theme.ts)

  【导航栏】
  - 自定义 H5 导航栏替代系统菜单
  - 个人版/企业版双布局
  - 未登录拦截 + 事件总线驱动登录弹窗

  【登录注册】
  - Modal 弹层(Portal)+ Tabs 切换
  - 登录/注册表单动态字段渲染
  - 记住密码 / 自动登录 / 用户协议

  【更新系统】
  - 自定义 API 版本检查(替代 electron-updater generic provider)
  - 手动下载 + SHA512 校验 + spawn NSIS 安装
  - 渲染进程更新状态机 Hook(useUpdater)
  - 检查更新按钮 + 下载进度展示

  【构建工具链】
  - build.mjs 构建总管(--win/--mac/--linux --personal/--enterprise --build/--clean/--sign/--upload/--publish)
  - scripts/ 自动化脚本(clean / generate-update-info / upload-oss / publish-release)
  - electron-builder NSIS 安装器(可选路径、多版本打包)
  - update-info.json 自动生成(fileSize/sha512/changelog/releaseDate)

  【代码质量】
  - ESLint + TypeScript strict + Prettier
  - husky + lint-staged(提交前自动检查)
  - commitlint(规范提交信息)
  - Playwright E2E + Vitest 单元测试框架
  - rollup-plugin-visualizer 打包体积分析

  【文档】
  - README.md(快速开始 + 目录结构 + 命令速查)
  - UpdateA.md(发布手册:API/签名/发版流程/数据库)
  - CHANGELOG.md(更新日志)
  - .env.example(构建环境变量模板)
This commit is contained in:
2026-06-03 15:00:51 +08:00
commit 767bb8b297
95 changed files with 20814 additions and 0 deletions

323
src/App.tsx Normal file
View File

@@ -0,0 +1,323 @@
import { useState, useEffect, useCallback } from 'react';
import {
Button,
Card,
Space,
Tag,
Typography,
Switch as AntSwitch,
Slider,
Progress,
theme as antTheme,
} from 'antd';
import {
ApiOutlined,
ThunderboltOutlined,
BulbOutlined,
BulbFilled,
DownloadOutlined,
SyncOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { getPlatformName, isDev } from './utils/platform';
import { gradientPrimary } from './theme/tokens';
import { useTheme } from './hooks/use-theme';
import { useUpdater } from './hooks/use-updater';
import { useAppContext } from './contexts/app-context';
import { NavBar } from './components/navbar';
import { LoginPage } from './pages/login';
import { on, off, EVENTS } from './utils/event-bus';
const { Title, Text } = Typography;
/**
* 根组件 — 主窗口
* 未登录时弹出登录 Modal已登录显示主页
* axios 返回 401 → 事件总线触发 → Modal 再次弹出
*/
function App() {
const { isDark, toggleTheme } = useTheme();
const { platform, edition, isLoggedIn, logout } = useAppContext();
const { token } = antTheme.useToken();
const [progress, setProgress] = useState(65);
const {
status: updateStatus,
progress: updateProgress,
info: updateInfo,
error: updateError,
checkForUpdates,
installUpdate,
} = useUpdater();
// 登录 Modal 状态
const [loginOpen, setLoginOpen] = useState(false);
// 首次加载时未登录 → 弹出
useEffect(() => {
if (!isLoggedIn) {
setLoginOpen(true);
}
}, [isLoggedIn]);
// 监听事件总线401 → 弹出登录 / 导航栏点击登录 → 弹出登录
useEffect(() => {
const handler = () => setLoginOpen(true);
on(EVENTS.AUTH_REQUIRED, handler);
on(EVENTS.SHOW_LOGIN, handler);
return () => {
off(EVENTS.AUTH_REQUIRED, handler);
off(EVENTS.SHOW_LOGIN, handler);
};
}, []);
const handleLoginClose = useCallback(() => {
setLoginOpen(false);
}, []);
return (
<>
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
{/* ========== 主页 ========== */}
<div
className="min-h-screen flex flex-col transition-colors duration-300"
style={{ background: token.colorBgLayout }}
>
{/* ======== 自定义导航栏(替代系统菜单) ======== */}
<NavBar />
{/* ======== 页面内容 ======== */}
<div className="flex-1 p-8">
<div className="mx-auto max-w-4xl space-y-6">
{/* ---- 标题区 + 主题开关 ---- */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-4">
<Title level={1} className="mb-0! gradient-primary-text">
HeiXiu ·
</Title>
<AntSwitch
checked={isDark}
onChange={toggleTheme}
checkedChildren={<BulbFilled />}
unCheckedChildren={<BulbOutlined />}
title={isDark ? '切换亮色模式' : '切换暗色模式'}
/>
</div>
<Text type="secondary">
Electron + React 19 + TypeScript + Ant Design + Tailwind CSS
</Text>
<br />
<Space size={4}>
<Tag color={isDark ? 'blue' : 'purple'}>
{isDark ? '🌙 暗色' : '☀️ 亮色'}
</Tag>
<Tag color={edition === 'enterprise' ? 'gold' : 'green'}>
{edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
{isLoggedIn ? (
<>
<Tag color="blue"></Tag>
<Button size="small" type="link" danger onClick={logout}>
退
</Button>
</>
) : (
<Tag color="default"></Tag>
)}
</Space>
</div>
{/* ---- 导航栏状态提示 ---- */}
<Card size="small" variant="borderless" className="rounded-lg">
<Text type="secondary" className="text-xs">
💡 使 <Text strong>H5 </Text>
{platform === 'darwin'
? ' macOS 保留了最小 Edit 菜单以支持 Cmd+C/V 等快捷键。'
: ' Windows/Linux 已完全移除系统菜单栏。'}
</Text>
</Card>
{/* ---- 主题色预览 ---- */}
<Card
title="🎨 蓝紫渐变主题色预览"
variant="borderless"
className="rounded-lg shadow-lg"
>
<Space orientation="vertical" size="large" className="w-full">
{/* 渐变展示 */}
<div>
<Text strong></Text>
<div className="flex gap-3 mt-2">
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: gradientPrimary }}
>
135°
</div>
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: 'linear-gradient(90deg, #4F46E5, #7C3AED)' }}
>
90°
</div>
</div>
</div>
{/* 组件示例 */}
<div>
<Text strong>Ant Design </Text>
<Space wrap className="mt-2">
<Button type="primary" icon={<ThunderboltOutlined />}>
</Button>
<Button></Button>
<Button type="dashed">线</Button>
<Button type="text"></Button>
<Button type="link"></Button>
</Space>
</div>
{/* 滑块 + 进度条 */}
<div>
<Text strong></Text>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm" style={{ color: token.colorTextSecondary }}>
:
</span>
<Slider
className="flex-1"
value={progress}
onChange={setProgress}
min={0}
max={100}
/>
<Progress
type="circle"
percent={progress}
size={48}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
</div>
</div>
</Space>
</Card>
{/* ---- 平台信息 ---- */}
<Card title="🖥️ 系统信息" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">{getPlatformName()}</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color={isDev() ? 'blue' : 'green'}>
{isDev() ? '开发模式' : '生产模式'}
</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">
H5 {edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
</div>
</Space>
</Card>
{/* ---- 版本更新 ---- */}
<Card title="🔄 版本更新" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
{/* 状态提示 */}
{updateStatus === 'checking' && (
<div className="flex items-center gap-2">
<SyncOutlined spin style={{ color: token.colorPrimary }} />
<Text type="secondary">...</Text>
</div>
)}
{updateStatus === 'no-update' && (
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text type="success"></Text>
</div>
)}
{updateStatus === 'error' && (
<div className="flex items-center gap-2">
<ExclamationCircleOutlined style={{ color: token.colorError }} />
<Text type="danger">{updateError?.message || '检查更新失败'}</Text>
</div>
)}
{/* 发现新版本 */}
{(updateStatus === 'available' || updateStatus === 'downloading') && (
<>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: token.colorPrimary }} />
<Text strong>{updateInfo?.version}</Text>
{updateInfo?.forceUpdate && <Tag color="red"></Tag>}
</div>
{updateInfo?.releaseNotes && (
<Text type="secondary" className="text-xs whitespace-pre-line">
{updateInfo.releaseNotes}
</Text>
)}
</>
)}
{/* 下载进度 */}
{updateStatus === 'downloading' && (
<Progress
percent={updateProgress.percent}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
)}
{/* 下载完成 */}
{updateStatus === 'downloaded' && (
<>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text></Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={installUpdate}
>
</Button>
</>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button
icon={<SyncOutlined spin={updateStatus === 'checking'} />}
onClick={checkForUpdates}
disabled={updateStatus === 'checking'}
>
</Button>
{isDev() && (
<Text type="secondary" className="text-xs">
5
</Text>
)}
</div>
</Space>
</Card>
</div>
</div>
</div>
</>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M498.602667 191.744a204.714667 204.714667 0 0 1 116.906666 372.8c133.162667 47.317333 229.077333 173.290667 231.893334 322.026667l0.085333 6.784h-64c0-157.333333-127.573333-284.885333-284.885333-284.885334-155.136 0-281.301333 123.968-284.821334 278.250667l-0.085333 6.613333h-64c0-151.68 96.810667-280.746667 232-328.810666a204.714667 204.714667 0 0 1 116.906667-372.8z m0 64a140.714667 140.714667 0 1 0 0 281.450667 140.714667 140.714667 0 0 0 0-281.450667z" /> </svg>

After

Width:  |  Height:  |  Size: 578 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M906.666667 202.666667v682.666666h-789.333334v-682.666666h789.333334z m-64 64h-661.333334v554.666666h661.333334v-554.666666zM326.464 510.72L434.432 618.666667l-107.946667 107.946666-45.269333-45.226666L343.914667 618.666667l-62.698667-62.72 45.248-45.226667zM733.909333 661.333333v64H481.792v-64h252.117333z m0-149.333333v64H481.792v-64h252.117333z m2.090667-149.333333v64h-448v-64h448z" /> </svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M714.666667 117.333333v64h170.666666v704h-746.666666v-704h170.666666v-64h405.333334z m-405.333334 128h-106.666666v576h618.666666v-576h-106.666666v64h-405.333334v-64zM704 618.666667v64H320v-64h384z m0-192v64H320v-64h384zM650.666667 181.333333h-277.333334v64h277.333334v-64z" /></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M512 71.274667l373.333333 176.362666v302.698667c0 162.048-133.034667 332.224-344.682666 392.469333l-8.853334 2.453334-19.797333 5.12-20.032-5.184C282.026667 889.045333 146.730667 724.096 138.986667 563.029333l-0.256-6.890666-0.064-308.48L512 71.253333z m-0.021333 70.784l-309.333334 146.112 0.085334 267.136c3.285333 129.6 112.789333 269.354667 288.341333 323.072l8.533333 2.538666 8.661334 2.389334 3.690666 0.96 3.541334-0.896c183.104-48.981333 298.581333-189.141333 305.493333-320.554667l0.256-6.250667 0.085333-6.229333V288.170667L511.978667 142.058667z m104.704 237.141333l45.248 45.248-203.648 203.648-143.296-143.296 45.248-45.248 98.026666 98.048 158.421334-158.4z" /></svg>

After

Width:  |  Height:  |  Size: 789 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M501.333333 48.789333l132.501334 132.522667 187.52 0.021333-0.042667 187.434667 132.565333 132.565333-132.565333 132.501334 0.021333 187.52-187.498666-0.042667-132.48 132.565333-132.586667-132.565333L181.333333 821.333333 181.333333 633.834667l-132.522666-132.48 132.522666-132.586667L181.333333 181.333333l187.434667-0.021333L501.333333 48.789333zM501.333333 139.285333l-106.026666 106.026667H245.333333v149.973333l-106.026666 106.026667 106.026666 106.026667V757.333333l149.973334-0.021333 106.026666 106.048 106.026667-106.048 149.994667 0.021333-0.021334-149.973333 106.048-106.048-106.048-106.026667 0.021334-149.973333h-149.994667l-106.026667-106.026667z m131.370667 246.549334l45.248 45.248-203.648 203.648-143.296-143.296 45.248-45.269334 98.048 98.048 158.4-158.378666z" /> </svg>

After

Width:  |  Height:  |  Size: 897 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M926.421333 157.12v341.333333H797.013333l0.021334 404.010667-156.266667-77.376-132.138667 66.773333-132.138666-66.773333-155.456 76.8-0.021334-403.434667H94.421333v-341.333333h832z m-193.408 202.645333h-448v439.125334l91.797334-45.333334 131.818666 66.56 131.882667-66.56 92.501333 45.781334V359.765333z m-96.064 244.202667v64h-256v-64h256z m0-147.050667v64h-256v-64h256z m225.472-235.818666h-704v213.333333h62.592l0.021334-138.666667h576l-0.021334 138.666667h65.408v-213.333333z" /></svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M458.666667 501.333333v341.333334h-298.666667v-341.333334h298.666667z m384 85.333334v256h-298.666667v-256h298.666667z m-448-21.333334h-170.666667v213.333334h170.666667v-213.333334z m384 85.333334h-170.666667v128h170.666667v-128z m64-490.666667v341.333333h-298.666667v-341.333333h298.666667z m-64 64h-170.666667v213.333333h170.666667v-213.333333z m-320-64v256h-298.666667v-256h298.666667z m-64 64h-170.666667v128h170.666667v-128z" /> </svg>

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M512 96c229.76 0 416 186.24 416 416S741.76 928 512 928 96 741.76 96 512 282.24 96 512 96z m0 64C317.589333 160 160 317.589333 160 512S317.589333 864 512 864 864 706.410667 864 512 706.410667 160 512 160z m105.386667 117.034667l45.226666 45.248-136.661333 136.682666H682.666667v64h-138.666667v81.941334H682.666667v64h-138.666667v100.8h-64v-100.8H341.333333v-64h138.666667v-81.941334H341.333333v-64h156.714667l-136.682667-136.682666 45.269334-45.226667L512 382.4l105.386667-105.386667z" /></svg>

After

Width:  |  Height:  |  Size: 600 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" ><path d="M341.333333 661.333333a117.376 117.376 0 0 1 112.917334 85.333334H875.093333v64H454.250667a117.376 117.376 0 0 1-225.834667 0H149.76v-64h78.656A117.376 117.376 0 0 1 341.333333 661.333333z m0 64a53.333333 53.333333 0 1 0 0 106.666667 53.333333 53.333333 0 0 0 0-106.666667z m341.333334-320a117.376 117.376 0 0 1 112.917333 85.333334h79.509333v64h-79.509333a117.376 117.376 0 0 1-225.834667 0H149.76v-64h419.989333A117.376 117.376 0 0 1 682.666667 405.333333z m0 64a53.333333 53.333333 0 1 0 0 106.666667 53.333333 53.333333 0 0 0 0-106.666667zM341.333333 149.333333a117.376 117.376 0 0 1 112.917334 85.333334H875.093333v64H454.250667a117.376 117.376 0 0 1-225.834667 0H149.76v-64h78.656A117.376 117.376 0 0 1 341.333333 149.333333z m0 64a53.333333 53.333333 0 1 0 0 106.666667 53.333333 53.333333 0 0 0 0-106.666667z" /></svg>

After

Width:  |  Height:  |  Size: 934 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M586.666667 160V490.666667h242.048l-111.296-111.296 45.248-45.269334 188.565333 188.586667-188.565333 188.544-45.248-45.248L828.714667 554.666667H586.666667v330.666666h-298.666667V661.333333h64v160h170.666667V554.666667H72.661333v-64H522.666667V224h-170.666667v159.104h-64V160z" /></svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@@ -0,0 +1,30 @@
<svg fill="currentColor" class="icon"
style="width: 1em;height: 1em;vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg">
<path d="M518.06 316.21c-109.48 0-198.23 88.75-198.23 198.23s88.75 198.23 198.23 198.23c109.48 0 198.23-88.75 198.23-198.23s-88.75-198.23-198.23-198.23z m0 364.07c-91.59 0-165.84-74.25-165.84-165.84S426.47 348.6 518.06 348.6 683.9 422.85 683.9 514.44s-74.25 165.84-165.84 165.84z"/>
<path d="M512.32 247.95l-6.69 234.94h20.51V239.98z"/>
<path d="M503.25 225.73a15.62 25.67 0 1 0 31.24 0 15.62 25.67 0 1 0-31.24 0Z"/>
<path d="M488.03 345.5a30.03 53.93 0 1 0 60.06 0 30.03 53.93 0 1 0-60.06 0Z"/>
<path d="M326.14 329.53l161.4 170.86 14.51-14.51-171.77-171.76z"/>
<path d="M304.016356 320.223887a25.67 15.62 44.999 1 0 22.089631-22.090401 25.67 15.62 44.999 1 0-22.089631 22.090401Z"/>
<path d="M377.943833 415.685666a53.93 30.03 44.999 1 0 42.468092-42.469575 53.93 30.03 44.999 1 0-42.468092 42.469575Z"/>
<path d="M252.18 518.86l234.95 6.69v-20.51H244.22z"/>
<path d="M204.3 512.31a25.67 15.62 0 1 0 51.34 0 25.67 15.62 0 1 0-51.34 0Z"/>
<path d="M295.8 513.12a53.93 30.03 0 1 0 107.86 0 53.93 30.03 0 1 0-107.86 0Z"/>
<path d="M333.76 705.03l170.86-161.39-14.5-14.51-171.76 171.76z"/>
<path d="M295.261178 734.259141a15.62 25.67 44.999 1 0 36.302229-36.303495 15.62 25.67 44.999 1 0-36.302229 36.303495Z"/>
<path d="M360.53674 670.125621a30.03 53.93 44.999 1 0 76.267206-76.269869 30.03 53.93 44.999 1 0-76.267206 76.269869Z"/>
<path d="M523.09 778.99l6.7-234.94h-20.52v242.91z"/>
<path d="M500.92 801.21a15.62 25.67 0 1 0 31.24 0 15.62 25.67 0 1 0-31.24 0Z"/>
<path d="M487.32 681.44a30.03 53.93 0 1 0 60.06 0 30.03 53.93 0 1 0-60.06 0Z"/>
<path d="M709.27 697.42l-161.4-170.87-14.5 14.51 171.76 171.76z"/>
<path d="M709.296892 728.80118a25.67 15.62 44.999 1 0 22.08963-22.090402 25.67 15.62 44.999 1 0-22.08963 22.090402Z"/>
<path d="M614.984176 653.726352a53.93 30.03 44.999 1 0 42.468092-42.469574 53.93 30.03 44.999 1 0-42.468092 42.469574Z"/>
<path d="M783.23 508.08l-234.94-6.69v20.51h242.9z"/>
<path d="M779.78 514.63a25.67 15.62 0 1 0 51.34 0 25.67 15.62 0 1 0-51.34 0Z"/>
<path d="M631.75 513.82a53.93 30.03 0 1 0 107.86 0 53.93 30.03 0 1 0-107.86 0Z"/>
<path d="M701.65 321.91L530.79 483.3l14.5 14.51 171.76-171.76z"/>
<path d="M703.832107 328.985384a15.62 25.67 44.999 1 0 36.302229-36.303496 15.62 25.67 44.999 1 0-36.302229 36.303496Z"/>
<path d="M598.591861 433.085985a30.03 53.93 44.999 1 0 76.267207-76.269869 30.03 53.93 44.999 1 0-76.267207 76.269869Z"/>
<path d="M518.06 470.39c-24.33 0-44.05 19.72-44.05 44.05s19.72 44.05 44.05 44.05 44.05-19.72 44.05-44.05-19.72-44.05-44.05-44.05z m0 65.43c-11.81 0-21.38-9.57-21.38-21.38 0-11.81 9.57-21.38 21.38-21.38 11.81 0 21.38 9.57 21.38 21.38 0 11.81-9.57 21.38-21.38 21.38z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M874.666667 553.877333V746.666667h-192v-128.021334h-64v258.986667h-64V554.666667h192v127.978666h64v-128.768h64zM469.333333 554.666667v320H149.333333V554.666667h320z m405.333334 256v64h-192v-64h192z m-469.333334-192h-192v192h192v-192z m-64 64v64h-64v-64h64z m128-533.333334v320H149.333333V149.333333h320z m405.333334 0v320H554.666667V149.333333h320z m-469.333334 64h-192v192h192v-192z m405.333334 0h-192v192h192v-192z m-469.333334 64v64h-64v-64h64z m405.333334 0v64h-64v-64h64z" /> </svg>

After

Width:  |  Height:  |  Size: 595 B

View File

@@ -0,0 +1,274 @@
为了更好地为您提供服务请您仔细阅读《用户服务协议》以下简称“本协议”。在您开始使用“船长Heixiu”平台及相关服务之前请您务必认真阅读并充分理解本协议特别是涉及免除或者限制责任的条款、权利许可和限制责任的条款、同意开通服务的条款、法律适用和争议解决的条款等。其中免除或者限制责任条款等重要内容将以加粗形式提示您注意您应重点阅读。如您未满18周岁请您在法定监护人陪同下仔细阅读并充分理解本协议并征得法定监护人的同意后使用“船长Heixiu”平台及相关服务。
除非您完全接受本协议的全部内容否则您无权注册、登录、使用以下统称“使用”“船长Heixiu”产品或者通过任何方式使用“船长Heixiu”服务及获得“船长Heixiu”平台提供的任何服务及产品本协议中统称“服务”。若您使用“船长Heixiu”平台及相关服务则视为您已充分理解本协议并承诺作为本协议的一方当事人接受协议的约束。
一、适用范围
1.1 本协议是您与[船长Heixiu]以下简称“我们”或“公司”之间就您注册、登录、使用“船长Heixiu”产品并获得“船长Heixiu”平台提供的相关服务所订立的协议。我们有权依“船长Heixiu”平台及相关服务或运营的需要单方决定安排或指定其关联方或公司认可的第三方继续运营“船长Heixiu”平台。并且就本协议项下涉及的某些服务可能会由公司的关联方或公司认可的第三方向您提供。您知晓并同意接受相关服务内容即视为接受相关权利义务关系亦接受本协议约束。
1.2 “用户”指所有直接或间接获取和使用“船长Heixiu”平台及相关服务的使用者包括自然人、法人和其他组织等。在本协议中称为“用户”或称“您”。
1.3 “船长Heixiu”指由我们合法拥有并运营的、标注名称为“船长Heixiu”的应用程序。公司有权对应用程序名称单方变更包括但不限于更名、新增等。公司发出更名公告后本协议所有约定适用更名后的应用程序。
1.4 本协议内容同时包括公司已经发布及后续可能不断发布的关于“船长Heixiu”平台及相关服务的相关协议、规则、说明等内容。前述内容一经正式发布公司将以适当的方式包括但不限于电话、短信、公告、系统通知等提醒您及时阅读。前述相关协议、规则构成是本协议不可分割的组成部分您使用或继续使用“船长Heixiu”平台视为您同意遵守前述相关协议、规则等内容。
二、使用“船长Heixiu”平台及相关服务
2.1 您使用“船长Heixiu”平台及相关服务可以访问“船长Heixiu”相关产品。若您并非从公司或经公司授权的第三方获取本平台的公司无法保证非官方版本的“船长Heixiu”平台能够正常使用或没有其他风险您因此遭受的损失与公司无关。
2.2 您知悉并同意“船长Heixiu”所提供的服务系网络虚拟商品采用先收费后服务的方式您为购买的服务所支付的款项非预付款或者存款、定金、储蓄卡等性质服务一经开通后不可转让或退款如因服务存在重大瑕疵导致您完全无法使用或法律法规要求必须退款或我们判断后认为可以退款的情形除外。为了保证您的用户权益请您通过官方渠道购买我们的服务一切通过非官方公布渠道取得的产品服务均不获保护并且我们有权中止或终止对您的服务由此给您带来的损失我们无须承担责任。
2.3公司可能为不同的终端设备开发了不同的应用程序平台版本您可自行根据需要使用“船长Heixiu”平台及相关服务或更新“船长Heixiu”版本如您不再需要使用“船长Heixiu”平台及相关服务可自行卸载。
2.4 为更好的提升用户体验及服务公司将不定期提供平台更新或改变包括但不限于平台修改、升级、功能强化、开发新服务等。为保证“船长Heixiu”平台及相关服务安全、提升用户体验度本平台及相关服务更新或部分服务内容更新后在可能的情况下公司将以包括但不限于弹窗、邮件、短信、系统提示、公告、站内信等方式提示用户用户有权选择是否接受更新版本或服务如用户不接受由于旧版本平台公司可能不再维护有可能无法使用新功能或部分功能将受到限制或不能继续使用后果由用户自行承担。
2.5 除非得到明示事先书面授权您不得以任何形式对“船长Heixiu”平台及相关服务进行包括但不限于改编、复制、传播、垂直搜索、镜像或交易等未经授权的访问或使用您违法使用造成的一切后果由您承担。
2.6 您理解您使用“船长Heixiu”平台及相关服务需自行准备与平台及相关服务有关的终端设备如电脑、手机等装置一旦您在其终端设备中打开“船长Heixiu”平台或访问船长Heixiu相关产品即视为您使用“船长Heixiu”平台及相关服务。为充分实现“船长Heixiu”的全部功能您可能需要将其终端设备联网且您清楚由您自行承担所需要的费用如流量费、上网费等
2.7 公司许可您个人的、可撤销的、不可转让的、非独占地和非商业的合法使用“船长Heixiu”平台及相关服务的权利。本协议未明示授权的其他一切权利仍由公司保留您在行使该些权利时须另行获得公司的书面许可同时公司如未行使前述任何权利并不构成对该权利的放弃。
2.8 如您发现“船长Heixiu”平台内存在任何侵犯您权利的内容请立即通过客服邮箱2006410116@dididiketang.com 通知公司提供您有相关权利的初步证据公司将会在15个工作日内处理您的投诉。如您在“船长Heixiu”内存在或涉及内容被相关权利人投诉侵权您也可以通过向客服邮箱2006410116@dididiketang.com提交反馈通知书及相关权利证明材料来向公司进行申诉公司同样将会在15个工作日内处理您的申诉。如您收到通知后未能在2个工作日内提供相关申诉材料则本公司有权依法采取暂停传输、封闭账号等措施。
三、关于“账号”
3.1 注册、登录“船长Heixiu”和相关服务
1当您注册、登录“船长Heixiu”及相关服务时您可以通过手机号创建账号我们将通过发送短信验证码来验证您的身份是否有效收集这些信息是为了帮助您完成注册。
2您也可以使用第三方账号(如微信等)登录进入船长Heixiu您此时将授权我们获取您在第三方平台注册的公开信息(头像、昵称等)并在您同意本协议后将您的第三方账号与您的船长Heixiu账户绑定使您可以通过第三方账号直接登录并使用本产品和相关服务。
3请您妥善保管您的任何信息因账号遗失、泄露等造成的损失由您自行承担。
3.2 账号注销
在您需要终止使用“船长Heixiu”账号服务时您可向“船长Heixiu”客服邮箱2006410116@dididiketang.com 申请注销您本人的账号但您仍应对您在注销账号前且使用“船长Heixiu”服务期间的行为承担相应责任。注销成功后我们将停止为您提供产品或服务并且您的账号记录、数据等将被删除无法恢复或提供法律法规另有规定的除外。具体注销事宜可查看《账号注销协议》。
3.3 您理解并承诺,您所设置的账号不得违反国家法律法规及公司的相关规则,您的账号名称、头像等注册信息及其他个人信息中不得出现违法和不良信息,未经他人许可不得用他人名义(包括但不限于冒用他人姓名、名称、字号、头像等足以让人引起混淆的方式)开设账号不得恶意注册“船长Heixiu”账号(包括但不限于频繁注册、批量注册账号等行为)。您在账号注册及使用过程中需遵守相关法律法规,不得实施任何侵害国家利益、损害其他公民权益、有害社会道德风尚的行为。公司有权对您提交的注册信息进行审核。
3.4 您在“船长Heixiu”中的注册账号仅限于您本人使用您对您的账号仅享有使用权未经公司书面同意禁止以任何形式赠与、借用、出租、转让、售卖或以其他方式许可他人使用该账号。如果公司发现或者有合理理由认为使用者并非账号初始注册人为保障账号安全公司有权立即暂停或终止向该注册账号提供服务或注销该账号。
3.5 您有责任维护您的个人账号的安全性与保密性您的注册账号下产生的任何活动均视为您自身的行为您应对此承担全部法律责任包括但不限于您在“船长Heixiu”上进行的款项支付等操作行为。您应高度重视对账号的保密在任何情况下不向他人透露账号及密码。若发现他人未经许可使用您的账号或发生其他任何安全漏洞问题时您应当立即通知公司。
3.6 您的账号在丢失或遗忘密码后可联系客服邮箱2006410116@dididiketang.com申诉按照客服要求完成验证找回账号或密码。公司特别提醒您应妥善保管您的账号和密码。当您使用完毕后应安全退出。因其他人恶意攻击或您自身原因或其他不可抗力因素而导致账号被盗、丢失公司不承担任何责任。
3.7 在注册、使用和管理账号时,您应保证注册账号时填写的身份信息的真实性,请您在注册、管理账号时使用真实、准确、合法、有效的相关身份证明材料及必要信息(包括但不限于您的联系电话等)。依照国家法律法规的规定为使用本平台及服务的部分功能您需要填写真实的身份信息请您按照相关法律规定完成实名认证并注意及时更新上述相关信息。若您提交的材料或提供的信息不准确、不真实、不规范、不合法或者公司有理由怀疑为错误、不实或不合法则公司有权拒绝为您提供相关服务或您可能无法使用“船长Heixiu”平台及相关服务或在使用过程中部分功能受到限制。
3.8 除自行注册“船长Heixiu”账号外您也可选择通过授权使用您合法拥有的包括但不限于公司和/或其关联公司、其他平台用户账号注册并登录使用“船长Heixiu”平台及相关服务但第三方平台或平台对此有限制或禁止的除外。当您以前述已有账号登录使用的应保证相应账号已进行实名注册登记并同意适用本协议中的相关约定。
3.9 如您通过“船长Heixiu”已注册的账号登录本公司的关联公司或合作方运营的系列客户端平台产品及产品时应遵守该平台产品及产品自身的《用户协议》及其他协议条款的规定。
四、用户个人信息保护
4.1 我们非常重视用户信息的保护,保护用户个人信息是公司的基本原则之一。
4.2 在使用“船长Heixiu”平台及相关服务的过程中您可能需要提供您的个人信息以便公司向您提供更好的服务和相应的技术支持。您理解并同意公司有权在更好的为您提供服务和遵守法律法规、本协议以及《船长Heixiu隐私政策》的前提下收集、使用含商业合作使用、存储和分享您的个人信息。同时我们将依法保护您浏览、修改、删除相关个人信息以及撤回同意的权利。更多关于用户个人信息保护的内容请参看《船长Heixiu隐私政策》。
五、 用户行为规范
5.1 用户行为要求
您应对使用本产品及相关服务的行为负责除非法律允许或者经公司事先书面许可您使用“船长Heixiu”平台及相关服务不得具有下列行为
5.1.1 使用未经公司授权或许可的任何插件、外挂、系统或第三方工具对“船长Heixiu”平台及相关服务的正常运行进行干扰、破坏、修改或施加其他影响。
5.1.2 利用或针对“船长Heixiu”平台及相关服务进行任何危害计算机网络安全的行为包括但不限于
1非法侵入他人网络、干扰他人网络正常功能、窃取网络数据等危害网络安全的活动
2提供专门用于从事侵入网络、干扰网络正常功能及防护措施、窃取网络数据等危害网络安全活动的程序、工具
3明知他人从事危害网络安全的活动的为其提供技术支持、广告推广、支付结算等帮助
4使用未经许可的数据或进入未经许可的服务器/账号;
5未经允许进入公众计算机网络或者他人计算机系统并删除、修改、增加存储信息
6未经许可企图探查、扫描、测试“船长Heixiu”系统或网络的弱点或其它实施破坏网络安全的行为
7企图干涉、破坏“船长Heixiu”系统或产品的正常运行故意传播恶意程序或病毒以及其他破坏干扰正常网络信息服务的行为
8伪造TCP/IP数据包名称或部分名称。
5.1.3 对“船长Heixiu”平台进行反向工程、反向汇编、编译或者以其他方式尝试发现本平台的源代码。
5.1.4 恶意注册“船长Heixiu”账号包括但不限于频繁、批量注册账号。
5.1.5 违反法律法规、本协议、公司的相关规则及侵犯他人合法权益的其他行为。
在任何情况下,如果公司有理由认为您的任何行为违反或可能违反上述约定的,公司可独立进行判断并处理,且在任何时候有权在不进行任何事先通知的情况下终止向用户提供服务,并追究相关责任。
5.2 信息内容规范
5.2.1 用户按规定完成实名认证后可以注册账号登录“船长Heixiu”平台使用我们提供的服务。
5.2.2 用户制作、传播的内容应自觉遵守法律法规、社会主义制度、国家利益、公民合法权益、社会公共秩序、道德风尚和信息真实性等“七条底线”要求,否则公司将立即采取限制功能、暂停服务、关闭账号等处理措施。用户不得发表下列信息:
1反对宪法确定的基本原则的
2危害国家安全泄露国家秘密
3颠覆国家政权推翻社会主义制度煽动分裂国家破坏国家统一的
4损害国家荣誉和利益的
5宣扬恐怖主义、极端主义的
6宣扬民族仇恨、民族歧视破坏民族团结的
7煽动地域歧视、地域仇恨的
8破坏国家宗教政策宣扬邪教和迷信的
9编造、散布谣言、虚假信息扰乱社会秩序、破坏社会稳定的
10散布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的
11危害网络安全、利用网络从事危害国家安全、荣誉和利益的
12侮辱或者诽谤他人侵害他人合法权益的
13对他人进行暴力恐吓、威胁实施人肉搜索的
14涉及他人隐私、个人信息或资料的
15散布污言秽语损害社会公序良俗的
16侵犯他人隐私权、名誉权、肖像权、知识产权等合法权益内容的
17散布商业广告或类似的商业招揽信息、过度营销信息及垃圾信息
18使用本产品常用语言文字以外的其他语言文字评论的
19与所评论的信息毫无关系的
20所发表的信息毫无意义的或刻意使用字符组合以逃避技术审核的
21侵害未成年人合法权益或者损害未成年人身心健康的
22未获他人允许偷拍、偷录他人侵害他人合法权利的
23包含恐怖、暴力血腥、高危险性、危害表演者自身或他人身心健康内容的包括但不限于以下情形
1任何暴力和/或自残行为内容;
2任何威胁生命健康、利用刀具等危险器械表演的危及自身或他人人身或财产权利的内容
3怂恿、诱导他人参与可能会造成人身伤害或导致死亡的危险或违法活动的内容。
24其他违反法律法规、政策及公序良俗禁止的信息
25干扰“船长Heixiu”正常运营或侵犯其他用户或第三方合法权益内容的其他信息。
六、信息内容使用规范
6.1 未经公司书面许可用户不得自行或授权、允许、协助任何第三人对本协议“船长Heixiu”平台及相关服务中信息内容进行如下行为
1复制、读取、采用“船长Heixiu”平台及相关服务的信息内容用于包括但不限于宣传、增加阅读量、浏览量等商业用途
2擅自编辑、整理、编排“船长Heixiu”平台及相关服务的信息内容后在船长Heixiu平台及相关服务的源页面以外的渠道进行展示
3采用包括但不限于特殊标识、特殊代码等任何形式的识别方法自行或协助第三人对“船长Heixiu”平台及相关服务的信息或内容产生流量、转移、劫持等不利影响
4其他非法获取“船长Heixiu”平台及相关服务的信息内容的行为。
6.2 经公司书面许可后用户对“船长Heixiu”平台的分享等行为还应符合以下规范
1对抓取、统计、获得的相关数据未经公司事先书面同意不得将数据以任何方式公示、提供、泄露给任何第三人
2不得对“船长Heixiu”平台及相关服务的源网页进行任何形式的任何改动包括但不限于“船长Heixiu”平台及相关服务的首页链接入口也不得对“船长Heixiu”平台及相关服务的源页面的展示进行任何形式的遮挡、插入、弹窗等妨碍
3应当采取安全、有效、严密的措施防止“船长Heixiu”平台及相关服务的信息内容被第三方通过包括但不限于“蜘蛛”spider程序等任何形式进行非法获取
4不得把相关数据内容用于公司书面许可范围之外的目的进行任何形式的销售和商业使用或向第三方泄露、提供或允许第三方以任何方式的使用。
七、生成合成内容标识
船长Heixiu将遵守适用法律对人工智能生成合成内容进行显示或隐式标识请您仔细阅读并理解相关的标识管理要求。
7.1 船长Heixiu中生成合成内容将会标识 "船长Heixiu AI生成"或其他我们不时更新且符合法律法规要求的标识.
7.2 未经我们允许您不得擅自删除、篡改、伪造或隐匿船长Heixiu的任何人工智能生成合成内容标识。
7.3 如果您为付费用户,你将根据付费权益享有要求生成合成内容不添加显示标识的权利,我们将根据您的要求提供不含显式标识的生成合成内容。但是,请您了解并承诺,如您使用网络信息内容传播服务发布 船长Heixiu 生成合成内容的,应当主动声明此内容为人工智能生成合成内容,并使用我们提供的标识功能进行标识。
7.4 您承诺并同意,您在自主标识生成合成内容的过程中会严格遵守相关法律法规的要求真实准确的使用标识,不会通过不正当标识手段损害我们或他人的合法权益。
八、违约处理
8.1 针对您违反本协议或其他服务条款的行为,公司有权独立判断并视情况采取预先警示、限制账号部分或者全部功能直至永久关闭账号等措施。公司有权公告处理结果,且有权根据实际情况决定是否恢复使用。对涉嫌违反法律法规、涉嫌违法犯罪的行为将保存有关记录,并依法向有关主管部门报告、配合有关主管部门调查。对已删除内容公司有权不予恢复及返还。
8.2 因您违反本协议或其他服务条款规定,引起第三方投诉或诉讼索赔的,您应当自行承担全部法律责任。因您的违法或违约行为导致公司及其关联方向任何第三方赔偿或遭受国家机关处罚的,您还应足额赔偿造成的全部损失。
8.3 公司尊重并保护法人、公民的知识产权、名誉权、姓名权、隐私权等合法权益。您保证使用“船长Heixiu”平台及相关服务生成的作品等不侵犯任何第三方的知识产权、名誉权、姓名权、肖像权、隐私权等权利及合法权益。否则公司有权在收到权利方或者相关方通知的情况下移除该涉嫌侵权内容。针对第三方提出的全部权利主张您应自行承担全部法律责任如因您的侵权行为导致公司及其关联公司、控制公司、继承公司遭受损失的包括但不限于经济、商誉等损失您还应足额赔偿。
8.4 您清楚并同意,任何情况下,公司对您的违约及赔偿总额,不超过您使用公司服务已支付的全部费用总额。
九、服务的变更、中断和终止
9.1 您理解并同意公司提供的“船长Heixiu”平台及相关服务是按照现有技术和条件所能达到的现状提供的。公司会尽最大努力向您提供服务确保服务的连贯性和安全性。您理解公司不能随时预见和防范法律、技术以及其他风险包括但不限于不可抗力、病毒、木马、黑客攻击、系统不稳定、第三方服务瑕疵及其他各种安全问题的侵扰等原因可能导致的服务中断、数据丢失以及其他的损失和风险。
9.2 您理解并同意公司基于经营策略的调整或运营需要有权在公告通知后修改、中断、中止或终止“船长Heixiu”平台及相关服务而无须向用户负责或承担任何赔偿责任。
9.3 您理解并同意,服务中止或终止后,公司没有义务向您提供或返还任何数据。
9.4 若用户申请注销账户,开通套餐激活软件时所部署的积分,不予退还。
十、知识产权
10.1 公司在“船长Heixiu”平台及相关服务中提供的内容包括但不限于平台、技术、程序、网页、文字、图片、图像、图表、版面设计、电子文档等的知识产权属于公司或合法第三方所有。公司提供本服务时所依托的平台著作权、专利权及其他知识产权均归公司所有。未经权利人许可您无权擅自使用包括但不限于通过任何机器人、蜘蛛等程序或设备监视、复制、传播、展示、镜像、上载、下载“船长Heixiu”平台及相关服务中的任何内容否则您应自行承担全部后果且本公司或第三方权利人有权追究您的法律责任。
10.2 您理解并同意您在使用“船长Heixiu”平台及相关服务时上传的文字、图片等均由您所有或已获合法授权含转授权不会违反法律法规的规定或侵犯任何第三方的合法权益。您通过“船长Heixiu”上传、合成并保存的任何内容的知识产权归属您或原始著作权人所有如因此引起任何纠纷等由您自行承担全部责任给公司造成损失的公司有权向您追索赔偿。
10.3 内容授权
1您知悉、理解并同意对于您通过“船长Heixiu”平台上传及合成的全部内容包括但不限文字、图片、图像等若您将内容在船长Heixiu平台内主动发布包括公开可见或者部分用户可见等形式的发布则视为您在此授予公司及其关联方一项全球范围内、免费、非独家、可再许可通过多层次的权利包括但不限于复制权、翻译权、汇编权、信息网络传播权、改编权、制作衍生品等使用范围包括但不限于在当前或其他产品、应用程序、产品或终端设备等。您在此确认并同意上述权利的授予包括在与“船长Heixiu”、公司和/或公司品牌有关的任何推广、广告、营销宣传中使用和以其他方式开发内容(全部或部分)的权利和许可。为避免疑惑,您同意,上述权利的授予包括使用、复制和展示您拥有或被许可使用并植入内容中的个人形象、肖像、姓名、商标、服务标志、品牌、名称、标识和公司标记(如有)以及任何其他品牌、营销或推广资产的权利和许可。
2您知悉、理解并同意若您通过“船长Heixiu”平台上传及合成的内容包括但不限文字、图片、图像等未在平台内进行发布仅在本地保存或在服务器内临时存储则公司及关联方不享有任何使用该内容的权利亦不会对该内容进行任何形式的展示、传播、改编或开发利用。但平台会对您生成的内容进行一定期间的临时存储。存储期限届满后平台将通过技术手段自动删除该内容删除后将无法恢复。您可在存储期限内自行导出或备份该内容因逾期未备份导致的内容丢失公司不承担责任。
10.4 公司为“船长Heixiu”开发、运营提供技术支持并对“船长Heixiu”平台及相关服务的开发和运营等过程中产生的所有数据和信息等享有全部权利。
10.5 请您在任何情况下都不要私自使用公司的包括但不限于“船长Heixiu”的任何商标、服务标记、商号、域名、产品名称或其他显著品牌特征等以下统称为“标识”。未经公司事先书面同意您不得将本条款前述标识以单独或结合任何方式展示、使用或申请注册商标、进行域名注册等也不得实施向他人明示或暗示有权展示、使用或其他有权处理该些标识的行为。由于您违反本协议使用公司上述商标、标识等给公司或他人造成损失的由您承担全部法律责任。
十一、免责声明
11.1 您理解并同意“船长Heixiu”平台及相关服务可能会受多种因素的影响或干扰公司不对下述事项向您作出任何保证(包括但不限于)
11.1.1 公司的平台及相关服务完全符合用户的使用要求;
11.1.2 公司不受干扰,及时、安全、可靠或不出现错误;用户经由公司取得的任何平台、服务或其他材料符合用户的期望;
11.1.3 平台中任何错误都将能得到更正。
11.2 对于涉及财产的网络信息、账户密码、广告或推广等信息的,用户请谨慎对待并自行进行判断,基于前述原因您因此遭受的利润、商业信誉、资料损失或其他有形或无形损失,公司不承担任何直接、间接、附带、特别、衍生性或惩罚性的赔偿责任。
11.3 用户理解并同意在使用“船长Heixiu”平台及相关服务过程中可能遇到不可抗力等因素不可抗力是指不能预见、不能克服并不能避免的客观事件包括但不限于政府行为、自然灾害、网络原因、黑客攻击、战争或任何其它类似事件。出现不可抗力情况时公司将努力在第一时间及时修复但因不可抗力给用户造成了损失用户同意公司不承担责任。
11.4 公司依据本协议约定获得处理违法违规内容的权利,该权利不构成公司的义务或承诺,公司不能保证及时发现违法行为或进行相应处理。
11.5 用户明确了解并同意:关于本协议服务,公司不提供任何种类的明示或暗示担保或条件,包括但不限于商业适售性、特定用途适用性等。您对本协议平台及相关服务的使用行为必须自行承担相应风险。
11.6 用户明确了解并同意,本协议是在保障遵守国家法律法规、维护公序良俗、保护他人合法权益,公司在能力范围内尽最大的努力按照相关法律法规进行判断,但并不保证公司判断完全与司法机关、行政机关的判断一致,如因此产生的后果用户已经理解并同意自行承担。
十二、未成年人使用条款
12.1 我们的产品主要是面向成年人若您是未满18周岁的未成年人在使用“船长Heixiu”平台及相关服务前应在您的父母或其他监护人监护、指导下阅读并同意本协议。
12.2 公司重视对未成年人个人信息的保护未成年用户在填写个人信息时请加强个人保护意识并谨慎对待并在监护人指导下正确使用“船长Heixiu”平台及相关服务。
12.3 如未成年用户违反法律法规、本协议内容,未成年人的监护人应依照法律规定承担因此而导致的一切后果。
12.4 未成年人用户特别提示
12.4.1 青少年及使用“船长Heixiu” 平台及相关服务应该在其监护人的监督指导下,在合理范围内正确学习使用网络,避免沉迷虚拟的网络空间,养成良好上网习惯。
12.4.2 青少年用户必须遵守《全国青少年网络文明公约》:
1要善于网上学习不浏览不良信息
2要诚实友好交流不侮辱欺诈他人
3要增强自护意识不随意约会网友
4要维护网络安全不破坏网络秩序
5要有益身心健康不沉溺虚拟时空。
12.4.3 为更好的保护未成年人隐私权益未成年人使用“船长Heixiu”平台开通付费会员或其他同等类型服务的应在监护人陪同下操作。公司提醒用户慎重上传包含未成年人素材的内容一经上传即视为用户同意“船长Heixiu”展示未成年人的信息、肖像、声音等且允许“船长Heixiu”依据本协议使用、处理该等与未成年人相关的内容。有此产生的责任由您自行承担。
十三、其他
13.1 本协议的成立、生效、履行、解释及争议的解决均应适用中华人民共和国大陆地区法律。本协议的签署地点为中华人民共和国重庆市,若您与公司发生争议的,双方应尽量友好协商解决,协商不成,您同意应将争议提交至本协议签订地(即重庆渝中区)有管辖权的人民法院管辖。
13.2 公司有权依据国家政策、技术条件、产品功能等变化需要而进行修改本协议公司会将修改后的协议予以发布。前述内容一经正式发布公司将以适当的方式短信、公告、弹窗、系统通知等提醒您更新的内容以便您及时了解本协议的最新版本。修改后的内容将构成本协议不可分割的组成部分您应同样遵守。您对修改后的协议有异议的请立即停止登录、使用“船长Heixiu”平台及相关服务若您登录或继续使用“船长Heixiu”平台及相关服务视为认可修改后的协议。
13.3 您和公司均是独立的主体,在任何情况下本协议不构成公司对用户的任何形式的明示、暗示担保或条件,双方之间亦不构成代理、合伙、合营或雇佣关系。
13.4 本协议中的标题仅为方便及阅读而设,并不影响本协议中任何规定的含义或解释。

View File

@@ -0,0 +1,73 @@
积分充值服务协议
本《积分充值服务协议》(以下简称 "本协议")由您与【重庆森焱启智科技有限责任公司】(以下简称 "我们")共同订立,旨在明确您使用我们提供的积分充值服务时双方的权利和义务。请您在使用积分充值服务前,仔细阅读本协议全部内容。如您对本协议内容有任何疑问,可向我们的客服咨询。当您使用积分充值服务时,即表示您已充分理解并自愿接受本协议的全部内容。
一、定义
1.积分:指由我们为您提供的,用于存储资金、进行交易支付等操作的电子虚拟账户。
2.充值:指您通过我们支持的支付渠道,将资金存入积分账户的行为。
3.支付渠道:包括但不限于第三方支付平台、网上银行、快捷支付等我们与相关合作机构共同提供的支付方式。
二、服务提供方的权利与义务
1.我们有权根据法律法规、监管要求、业务发展等情况,对积分充值服务的功能、支付渠道、充值限额等进行调整,并通过官方网站、短信、邮箱等一种或多种方式通知您。如您继续使用充值服务,即视为接受调整后的内容。
2.我们承诺采取合理的技术和管理措施,保障积分充值服务的正常运行,保护您的资金安全和个人信息安全。但因不可抗力、通信网络故障、第三方支付渠道问题等非我们原因导致的服务中断、资金损失等情况,我们不承担责任,但会尽力协助您解决问题。
3.我们有权对您的充值行为进行监测和管理,如发现异常充值行为(包括但不限于涉嫌洗钱、诈骗、套现等违法违规行为),我们有权暂停或终止为您提供充值服务,并有权采取相应的风险控制措施,包括但不限于冻结您的积分账户资金、限制交易等,并将相关情况报告监管部门。
4.我们应按照约定及时处理您的充值请求,并在充值成功后,及时更新您积分账户的余额信息。
三、用户的权力与义务
1.您有权在遵守本协议及相关法律法规的前提下,使用我们提供的积分充值服务,并在充值成功后,合法合理地使用积分账户内的资金。
2.您应确保提供的注册信息、身份信息等资料真实、准确、完整、有效,并及时更新。如因您提供的信息不真实、不准确、不完整或未及时更新,导致无法使用充值服务或产生其他不利后果,由您自行承担责任。
3.您应妥善保管自己的积分账户信息(包括但不限于账户名、密码、验证码等)及支付工具信息(如银行卡号、支付密码等),不得将其泄露给任何第三方。如因您自身原因导致账户信息或支付工具信息泄露,造成资金损失的,由您自行承担责任。
4.您应按照我们规定的充值流程和要求进行充值操作,并确认充值金额、支付方式等信息无误后提交充值请求。如因您操作失误导致充值错误(如充错账户、重复充值等),我们将根据实际情况协助您处理,但不保证一定能够追回资金或进行退款,相关损失由您自行承担。
5.您承诺使用充值服务进行的交易活动均符合法律法规、社会公德及本协议的约定,不得利用积分充值服务从事任何违法违规、损害他人合法权益或违反公序良俗的行为。如您违反上述规定,我们有权采取相应措施追究您的责任,包括但不限于终止服务、要求您承担赔偿责任等,并将相关情况报告监管部门。
四、充值操作与交易规则
1.充值方式:您可通过我们支持的支付渠道进行充值,不同支付渠道可能有不同的到账时间,具体以充值页面展示的信息为准。
2.充值限额:为保障您的资金安全和防范风险,我们对单笔充值金额、单日充值金额、单月充值金额等设置了限额。如您需要调整充值限额,可按照我们规定的流程申请,但我们有权根据您的账户情况、风险评估结果等决定是否同意您的申请。
3.到账时间:充值资金一般会在您提交充值请求并支付成功后的一定时间内到账,具体到账时间以各支付渠道的实际处理时间为准。如遇网络繁忙、系统维护、支付渠道故障等特殊情况,到账时间可能会延迟。如您的充值资金长时间未到账,请及时联系我们的客服人员进行查询和处理。
4.交易凭证您的充值记录将作为交易凭证您可以在订单明细中查询充值历史记录。如对充值交易有疑问您应在充值交易发生后的7个工作日内我们提出异议并提供相关证明材料。我们将在收到您的异议后进行核实并在规定时间内将处理结果告知您。
五、资金管理与安全
1.您积分账户内的资金属于您所有,我们不会将您的资金用于除本协议约定用途之外的其他任何目的。
2.我们将采取多种安全技术和措施保障您积分账户资金的安全,包括但不限于数据加密、身份认证、风险监控等。但由于互联网存在诸多不确定性,如您发现积分账户存在异常交易或资金被盗用等情况,请立即联系我们的客服人员,并配合我们进行调查和处理。我们将在核实情况后,根据实际情况采取相应措施保障您的资金安全,如冻结账户、协助追回资金等。但如因您自身原因导致资金损失(如未妥善保管账户信息等),我们不承担赔偿责任。
六、协议的变更与终止
1.我们有权根据法律法规变化、业务调整等情况对本协议内容进行变更。变更后的协议将通过官方网站、短信、邮件等一种或多种方式通知您。如您在变更后的协议生效后继续使用积分充值服务,即视为您已充分阅读、理解并接受变更后的协议内容;如您不同意变更后的协议内容,您应立即停止使用积分充值服务。
2.在以下情况下,我们有权单方面终止本协议,并停止为您提供积分充值服务:
您违反本协议约定,从事违法违规、损害他人合法权益或违反公序良俗的行为;
您的账户存在异常交易行为或涉嫌洗钱、诈骗、套现等违法违规风险,经我们调查核实后情况属实;
因不可抗力、法律法规变化、监管要求等原因,导致我们无法继续提供积分充值服务。
3.本协议终止后,我们将停止为您提供充值服务,并根据法律法规规定和本协议约定处理您积分账户内的剩余资金。如您的账户不存在未处理的交易纠纷、违法违规行为等情况,我们将按照您指定的方式退还您账户内的剩余资金;如您的账户存在未处理的交易纠纷、违法违规行为等情况,我们将在纠纷解决或违规行为处理完毕后,再按照上述方式处理剩余资金。
七、争议解决
如您与我们在本协议履行过程中发生争议,应首先通过友好协商解决;协商不成的,任何一方均有权向重庆市渝中区人民法院提起诉讼。
八、其他条款
1.本协议自您使用积分充值服务之日起生效,对您和我们均具有法律约束力。
2.本协议的任何条款被认定为无效或不可执行,不影响其他条款的效力和执行。
3.我们未行使或延迟行使本协议项下的任何权利,不构成对该权利的放弃;我们对您的任何违约行为给予任何宽限或延缓,均不影响我们对您后续违约行为追究责任的权利。
4.为了提升服务质量,缩短充值到账时间,我们特此委托济南煜邦炫盛智能科技有限责任公司代为收取您的充值金额,特此告知。
九、如何联系我们
1.本协议版权为我们所有,在法律允许的范围内,我们拥有解释和修改的权利。
2.如您在使用我们的产品与/或服务时对协议有任何问题、举报或投诉可通过我们的客服及相关负责人邮箱2006410116@dididiketang.com联系我们。

BIN
src/assets/logo/HX.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
src/assets/logo/HX2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
src/assets/logo/heixiu.icns Normal file

Binary file not shown.

BIN
src/assets/logo/heixiu.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,61 @@
// ============================================================
// AppProvider — 应用状态 Provider 组件
// ============================================================
import { useState, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '../utils/platform';
import { parseEdition } from '@shared/constants/app';
import { safeIpcOn, safeIpcOff } from '../utils/ipc';
import { AppContext } from '../contexts/app-context';
import type { AppContextValue } from '../contexts/app-context';
interface AppProviderProps {
children: ReactNode;
}
export function AppProvider({ children }: AppProviderProps) {
const [platform] = useState<Platform>(getPlatform);
const [edition] = useState<Edition>(() => {
return parseEdition(import.meta.env.VITE_EDITION);
});
const [isLoggedIn, setIsLoggedIn] = useState(false);
const login = useCallback(() => {
// TODO: 接入真实登录流程
setIsLoggedIn(true);
}, []);
const logout = useCallback(() => {
// TODO: 清理 token、用户状态等
setIsLoggedIn(false);
}, []);
// 监听主进程推送的平台/版本信息
useEffect(() => {
const handler = (_event: unknown, info: unknown) => {
const data = info as { platform?: string; edition?: string } | undefined;
if (data) {
if (isDev()) {
console.log('[AppContext] 主进程平台信息:', data);
}
}
};
safeIpcOn('platform-info', handler);
return () => {
safeIpcOff('platform-info', handler);
};
}, []);
const value: AppContextValue = {
platform,
edition,
isLoggedIn,
login,
logout,
isDevMode: isDev(),
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

View File

@@ -0,0 +1,72 @@
// ============================================================
// ThemeProvider — 主题 Provider 组件
// ============================================================
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import { ConfigProvider } from 'antd';
import { getThemeConfig } from '../theme/antd-theme';
import {
ThemeContext,
readStoredTheme,
writeStoredTheme,
applyHtmlTheme,
STORAGE_KEY,
} from '../hooks/use-theme';
import type { ThemeContextValue } from '../hooks/use-theme';
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const [isDark, setIsDark] = useState<boolean>(readStoredTheme);
// 挂载时同步
useEffect(() => {
applyHtmlTheme(isDark);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const toggleTheme = useCallback(() => {
setIsDark((prev) => {
const next = !prev;
writeStoredTheme(next);
applyHtmlTheme(next);
return next;
});
}, []);
const setLight = useCallback(() => {
setIsDark(false);
writeStoredTheme(false);
applyHtmlTheme(false);
}, []);
const setDark = useCallback(() => {
setIsDark(true);
writeStoredTheme(true);
applyHtmlTheme(true);
}, []);
// 监听系统主题变化(仅用户从未手动切换时跟随)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === null) {
setIsDark(e.matches);
applyHtmlTheme(e.matches);
}
};
mq.addEventListener('change', handleChange);
return () => mq.removeEventListener('change', handleChange);
}, []);
const ctx: ThemeContextValue = { isDark, toggleTheme, setLight, setDark };
return (
<ThemeContext.Provider value={ctx}>
<ConfigProvider theme={getThemeConfig(isDark)}>{children}</ConfigProvider>
</ThemeContext.Provider>
);
}

View File

@@ -0,0 +1,77 @@
// ============================================================
// LegalTextModal — 协议/法律文本展示弹窗
// 从 src/assets/legal/ 加载 .txt 文件,主题适配渲染
// ============================================================
import { Modal, Typography } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme';
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';
import pointsAgreement from '@/assets/legal/积分充值服务协议.txt?raw';
const { Paragraph } = Typography;
/** 协议名称 → 文本内容映射 */
const legalTexts: Record<string, { title: string; content: string }> = {
service: { title: '用户服务协议', content: serviceAgreement },
points: { title: '积分充值服务协议', content: pointsAgreement },
};
interface LegalTextModalProps {
open: boolean;
/** 协议标识:'service' | 'points' */
type: 'service' | 'points';
onClose: () => void;
}
export function LegalTextModal({ open, type, onClose }: LegalTextModalProps) {
const { isDark } = useTheme();
const legal = legalTexts[type];
if (!legal) return null;
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={640}
centered
title={
<span className="flex items-center gap-2">
<FileTextOutlined />
{legal.title}
</span>
}
styles={{
body: {
maxHeight: '60vh',
overflowY: 'auto',
padding: '20px 24px',
background: isDark ? '#1A1730' : '#FFFFFF',
color: isDark ? '#E8E6F0' : '#1E1B4B',
fontSize: 14,
lineHeight: 2,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--font-family-base)',
},
}}
>
<Paragraph
style={{
color: isDark ? '#E8E6F0' : '#1E1B4B',
fontSize: 14,
lineHeight: 2,
whiteSpace: 'pre-wrap',
margin: 0,
}}
>
{legal.content}
</Paragraph>
</Modal>
);
}

View File

@@ -0,0 +1,147 @@
// ============================================================
// AnnouncementDrawer — 公告侧拉抽屉SPA 内部弹出)
// 数据来源GET /api/v1/announcements
// ============================================================
import { useEffect, useState, useCallback } from 'react';
import { Drawer, List, Typography, Empty, Tag, Spin } from 'antd';
import { NotificationOutlined } from '@ant-design/icons';
import { fetchAnnouncements, type Announcement } from '@/services/modules';
const { Text, Paragraph } = Typography;
// ---------- 辅助 ----------
/** 公告类型 → Tag 颜色映射 */
const typeColorMap: Record<string, string> = {
system: 'blue',
feature: 'purple',
activity: 'orange',
maintenance: 'red',
};
/** 公告类型 → 中文标签 */
const typeLabelMap: Record<string, string> = {
system: '系统',
feature: '功能',
activity: '活动',
maintenance: '维护',
};
/** 格式化日期YYYY-MM-DD */
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
} catch {
return iso;
}
}
// ---------- 组件 ----------
interface AnnouncementDrawerProps {
open: boolean;
onClose: () => void;
}
export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
const [list, setList] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 打开抽屉时请求公告列表
const loadAnnouncements = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchAnnouncements();
// 仅展示有效公告
setList(data.filter((item) => item.is_active));
} catch (err) {
setError((err as Error).message || '加载公告失败');
setList([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadAnnouncements();
}
}, [open, loadAnnouncements]);
// ---- 渲染 ----
return (
<Drawer
title={
<span className="flex items-center gap-2">
<NotificationOutlined />
</span>
}
placement="right"
size={380}
open={open}
onClose={onClose}
styles={{ body: { padding: 0 } }}
>
{loading ? (
<div className="flex items-center justify-center h-64">
<Spin description="加载中..." />
</div>
) : error ? (
<div className="flex items-center justify-center h-64">
<Empty description={error} />
</div>
) : list.length === 0 ? (
<div className="flex items-center justify-center h-64">
<Empty description="暂无公告" />
</div>
) : (
<List
dataSource={list}
renderItem={(item) => (
<List.Item className="px-6! py-4!">
<div className="w-full space-y-2">
{/* 标题 + 类型标签 */}
<div className="flex items-center justify-between gap-2">
<Text strong className="text-sm flex-1" ellipsis>
{item.pinned && '📌 '}
{item.title}
</Text>
<Tag
color={typeColorMap[item.type] || 'default'}
className="m-0! text-xs shrink-0"
>
{typeLabelMap[item.type] || item.type}
</Tag>
</div>
{/* 内容 */}
<Paragraph
className="mb-0! text-xs"
type="secondary"
ellipsis={{ rows: 2 }}
>
{item.content}
</Paragraph>
{/* 日期 */}
<Text type="secondary" className="text-xs">
{formatDate(item.created_at)}
</Text>
</div>
</List.Item>
)}
/>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,135 @@
// ============================================================
// NavBar — 自定义顶部导航栏(替代系统菜单)
//
// 逻辑:
// - 所有导航项默认全部显示(不再按登录状态隐藏)
// - 未登录时点击需登录的项 → 弹出提示"请先登录"并触发登录 Modal
// - 登录/注册 与 退出登录 仍按登录状态互斥显示
// ============================================================
import { useState, useCallback, useMemo } from 'react';
import { App } from 'antd';
import { useAppContext } from '@/contexts/app-context';
import { useTheme } from '@/hooks/use-theme';
import { emit, EVENTS } from '@/utils/event-bus.ts';
import { NavItem } from './NavItem';
import { AnnouncementDrawer } from './AnnouncementDrawer';
import { personalLeft, personalRight, enterpriseLeft, enterpriseRight } from './nav-config';
import type { NavItemConfig } from './types';
const NAVBAR_HEIGHT = 56;
export function NavBar() {
const { platform, edition, isLoggedIn, logout } = useAppContext();
const { isDark } = useTheme();
const { message } = App.useApp();
const [drawerOpen, setDrawerOpen] = useState(false);
// 根据版本选择导航项
const { leftItems, rightItems } = useMemo(() => {
const isEnterprise = edition === 'enterprise';
return {
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
rightItems: isEnterprise ? enterpriseRight : personalRight,
};
}, [edition]);
// 过滤规则:
// requireGuest → 仅未登录时可见(登录/注册)
// "退出登录" → 仅已登录时可见
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
const visibleLeft = useMemo(
() =>
leftItems.filter((item) => {
if (item.requireGuest && isLoggedIn) return false;
return !(item.key === 'logout' && !isLoggedIn);
}),
[leftItems, isLoggedIn],
);
const visibleRight = useMemo(
() =>
rightItems.filter((item) => {
if (item.requireGuest && isLoggedIn) return false;
return !(item.key === 'logout' && !isLoggedIn);
}),
[rightItems, isLoggedIn],
);
// 点击导航项
const handleNavClick = useCallback(
(item: NavItemConfig) => {
// 需要登录但未登录 → 提示并弹出登录 Modal
if (item.requireLogin && !isLoggedIn) {
message.warning('请先登录');
emit(EVENTS.SHOW_LOGIN);
return;
}
switch (item.action) {
case 'drawer':
if (item.key === 'announcements') {
setDrawerOpen(true);
}
break;
case 'button':
if (item.key === 'logout') {
logout();
}
break;
case 'external':
if (item.target) {
window.open(item.target, '_blank');
}
break;
case 'navigate':
case 'spa':
if (item.key === 'auth') {
emit(EVENTS.SHOW_LOGIN);
} else {
console.log('[NavBar] 导航至:', item.target);
}
break;
}
},
[isLoggedIn, logout, message],
);
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
return (
<>
<div
className="navbar-drag-region flex items-center justify-between select-none"
style={{
height: NAVBAR_HEIGHT,
paddingLeft: macPaddingLeft,
paddingRight: 12,
background: isDark
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)',
}}
>
<div className="navbar-no-drag flex items-center gap-0.5">
{visibleLeft.map((item) => (
<NavItem key={item.key} item={item} onClick={handleNavClick} />
))}
</div>
<div className="navbar-no-drag flex items-center gap-0.5">
{visibleRight.map((item) => (
<NavItem key={item.key} item={item} onClick={handleNavClick} />
))}
</div>
</div>
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,52 @@
// ============================================================
// NavItem — 单个导航按钮
// 布局图标SVG在上文字在下
// ============================================================
import { useTheme } from '@/hooks/use-theme.ts';
import type { NavItemConfig } from './types';
interface NavItemProps {
item: NavItemConfig;
onClick: (item: NavItemConfig) => void;
}
export function NavItem({ item, onClick }: NavItemProps) {
const { isDark } = useTheme();
const isLogout = item.key === 'logout';
return (
<button
type="button"
onClick={() => onClick(item)}
title={item.label}
className="navbar-no-drag flex flex-col items-center justify-center gap-0.5 px-3 py-1 rounded-md
transition-all duration-200 cursor-pointer border-0
hover:brightness-95 active:scale-95"
style={{
background: isLogout ? 'transparent' : 'transparent',
minWidth: 56,
minHeight: 48,
}}
>
{/* SVG 图标 — 内联渲染currentColor 继承父元素 color */}
{item.iconSrc && (
<span
className="w-5 h-5 inline-block opacity-80"
style={{ color: '#6366F1' }}
dangerouslySetInnerHTML={{ __html: item.iconSrc }}
/>
)}
{/* 文字标签 */}
<span
className="text-xs leading-tight whitespace-nowrap select-none"
style={{
color: isLogout ? (isDark ? '#F87171' : '#EF4444') : isDark ? '#E8E6F0' : '#1E1B4B',
}}
>
{item.label}
</span>
</button>
);
}

View File

@@ -0,0 +1,3 @@
export { NavBar } from './NavBar';
export { AnnouncementDrawer } from './AnnouncementDrawer';
export type { NavItemConfig, NavBarConfig, NavAction } from './types';

View File

@@ -0,0 +1,198 @@
// ============================================================
// 导航栏配置 —— 按版本(个人版 / 企业版)定义导航项
// 每个导航项的 iconSrc 指向 src/assets/header_actions/ 下的 SVG 图标
// ============================================================
import type { NavItemConfig } from './types';
// ---------- SVG 图标导入(?raw = 内联渲染,使 currentColor 生效)----------
import complianceAssetsSvg from '@/assets/header_actions/compliance_assets.svg?raw';
import announcementsSvg from '@/assets/header_actions/announcements.svg?raw';
import accountSvg from '@/assets/header_actions/account.svg?raw';
import membershipSvg from '@/assets/header_actions/membership.svg?raw';
import rechargeSvg from '@/assets/header_actions/recharge.svg?raw';
import billingSvg from '@/assets/header_actions/billing.svg?raw';
import orderDetailsSvg from '@/assets/header_actions/order_details.svg?raw';
import wechatWorkSvg from '@/assets/header_actions/wechat_work.svg?raw';
import settingsSvg from '@/assets/header_actions/settings.svg?raw';
import signOutSvg from '@/assets/header_actions/sign_out.svg?raw';
import projectManagementSvg from '@/assets/header_actions/project_management.svg?raw';
// ============================================================
// 个人版导航项
// ============================================================
/** 个人版 — 左侧 */
const personalLeft: NavItemConfig[] = [
{
key: 'compliance-assets',
label: '合规素材库',
iconSrc: complianceAssetsSvg,
action: 'navigate',
target: '/compliance-assets',
position: 'left',
},
];
/** 个人版 — 右侧(从左至右排列) */
const personalRight: NavItemConfig[] = [
{
key: 'announcements',
label: '公告',
iconSrc: announcementsSvg,
action: 'drawer',
position: 'right',
},
{
key: 'account',
label: '查帐户',
iconSrc: accountSvg,
action: 'navigate',
target: '/account',
position: 'right',
requireLogin: true,
},
{
key: 'vip-activate',
label: '开会员/激活',
iconSrc: membershipSvg,
action: 'navigate',
target: '/vip',
position: 'right',
},
{
key: 'recharge-points',
label: '充值积分',
iconSrc: rechargeSvg,
action: 'navigate',
target: '/recharge',
position: 'right',
requireLogin: true,
},
{
key: 'billing-history',
label: '消费记录',
iconSrc: billingSvg,
action: 'navigate',
target: '/billing',
position: 'right',
requireLogin: true,
},
{
key: 'order-details',
label: '订单明细',
iconSrc: orderDetailsSvg,
action: 'navigate',
target: '/orders',
position: 'right',
requireLogin: true,
},
{
key: 'wechat-work',
label: '企业微信',
iconSrc: wechatWorkSvg,
action: 'external',
target: 'https://work.weixin.qq.com/',
position: 'right',
},
{
key: 'settings',
label: '设置',
iconSrc: settingsSvg,
action: 'navigate',
target: '/settings',
position: 'right',
},
{
key: 'auth',
label: '登录/注册',
iconSrc: accountSvg,
action: 'navigate',
target: '/login',
position: 'right',
requireGuest: true,
},
{
key: 'logout',
label: '退出登录',
iconSrc: signOutSvg,
action: 'button',
position: 'right',
requireLogin: true,
},
];
// ============================================================
// 企业版导航项
// ============================================================
/** 企业版 — 左侧 */
const enterpriseLeft: NavItemConfig[] = [
{
key: 'compliance-assets',
label: '合规素材库',
iconSrc: complianceAssetsSvg,
action: 'navigate',
target: '/compliance-assets',
position: 'left',
},
{
key: 'project-management',
label: '项目管理',
iconSrc: projectManagementSvg,
action: 'navigate',
target: '/projects',
position: 'left',
},
];
/** 企业版 — 右侧(从左至右排列) */
const enterpriseRight: NavItemConfig[] = [
{
key: 'announcements',
label: '公告',
iconSrc: announcementsSvg,
action: 'drawer',
position: 'right',
},
{
key: 'quota-check',
label: '查配额',
iconSrc: accountSvg,
action: 'navigate',
target: '/quota',
position: 'right',
requireLogin: false,
},
{
key: 'settings',
label: '设置',
iconSrc: settingsSvg,
action: 'navigate',
target: '/settings',
position: 'right',
},
{
key: 'auth',
label: '登录/注册',
iconSrc: accountSvg,
action: 'navigate',
target: '/login',
position: 'right',
requireGuest: true,
},
{
key: 'logout',
label: '退出登录',
iconSrc: signOutSvg,
action: 'button',
position: 'right',
requireLogin: true,
},
];
// ============================================================
// 导出
// ============================================================
export { personalLeft, personalRight, enterpriseLeft, enterpriseRight };

View File

@@ -0,0 +1,41 @@
// ============================================================
// 导航栏类型定义
// ============================================================
import type { Edition } from '@shared/types';
/** 导航项的行为类型 */
export type NavAction =
| 'navigate' // 新开窗口
| 'spa' // 单页应用内导航(当前窗口)
| 'drawer' // 侧拉抽屉(公告等)
| 'button' // 纯按钮行为(退出登录)
| 'external'; // 外部链接
/** 单个导航项配置 */
export interface NavItemConfig {
/** 唯一标识 */
key: string;
/** 显示文字 */
label: string;
/** SVG 图标资源路径Vite import 后的 URL */
iconSrc?: string;
/** 行为类型 */
action: NavAction;
/** 点击回调 / 路由路径 / 外部 URL取决于 action */
target?: string;
/** 仅在指定版本下显示 */
edition?: Edition | Edition[];
/** 仅已登录时显示 */
requireLogin?: boolean;
/** 仅未登录时显示 */
requireGuest?: boolean;
/** 排列位置 */
position: 'left' | 'right';
}
/** 导航栏配置(按版本分组) */
export interface NavBarConfig {
left: NavItemConfig[];
right: NavItemConfig[];
}

View File

@@ -0,0 +1,35 @@
// ============================================================
// AppContext — 全局应用状态
// ============================================================
import { createContext, useContext } from 'react';
import type { Platform, Edition } from '@shared/types';
// ---------- Context 类型 ----------
export interface AppContextValue {
/** 当前操作系统平台 */
platform: Platform;
/** 产品版本(个人版 / 企业版) */
edition: Edition;
/** 用户是否已登录 */
isLoggedIn: boolean;
/** 登录TODO: 接入真实认证) */
login: () => void;
/** 退出登录 */
logout: () => void;
/** 是否为开发环境 */
isDevMode: boolean;
}
export const AppContext = createContext<AppContextValue | null>(null);
// ---------- Consumer Hook ----------
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
}
return ctx;
}

61
src/hooks/use-theme.ts Normal file
View File

@@ -0,0 +1,61 @@
// ============================================================
// useTheme — 亮/暗主题切换 Hook
// ============================================================
import { createContext, useContext } from 'react';
// ---------- Context 类型 ----------
export interface ThemeContextValue {
isDark: boolean;
toggleTheme: () => void;
setLight: () => void;
setDark: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
// ---------- 工具函数 ----------
export const STORAGE_KEY = 'ele-heixiu-theme';
export function readStoredTheme(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark') return true;
if (stored === 'light') return false;
} catch {
/* 静默忽略 */
}
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
}
export function writeStoredTheme(isDark: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
} catch {
/* 静默忽略 */
}
}
export function applyHtmlTheme(isDark: boolean): void {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
// ---------- Consumer Hook ----------
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
}
return ctx;
}

136
src/hooks/use-updater.ts Normal file
View File

@@ -0,0 +1,136 @@
// ============================================================
// useUpdater — 渲染进程中的更新状态管理
//
// 用法:
// const { status, progress, checkForUpdates, installUpdate } = useUpdater();
//
// 状态机idle → checking → (no-update | available → downloading → downloaded → idle)
// ============================================================
import { useState, useEffect, useCallback, useRef } from 'react';
import { safeIpcInvoke, safeIpcOn, safeIpcOff, safeIpcSend } from '@/utils/ipc';
// ---------- 类型 ----------
export type UpdaterStatus =
| 'idle' // 空闲
| 'checking' // 正在检查
| 'no-update' // 已是最新
| 'available' // 发现新版本,准备下载
| 'downloading' // 下载中
| 'downloaded' // 下载完成,待安装
| 'error'; // 出错
export interface UpdateInfo {
version: string;
releaseDate?: string;
releaseNotes?: string;
forceUpdate?: boolean;
}
export interface UpdateProgress {
percent: number;
bytesPerSecond?: number;
transferred?: number;
total?: number;
}
export interface UpdateError {
message: string;
}
// ---------- IPC 通道名(与主进程 updater.ts 保持一致)----------
const CH = {
UPDATE_AVAILABLE: 'update-available',
UPDATE_PROGRESS: 'update-progress',
UPDATE_DOWNLOADED: 'update-downloaded',
UPDATE_ERROR: 'update-error',
UPDATE_NOT_AVAILABLE: 'update-not-available',
INSTALL_UPDATE: 'install-update',
CHECK_FOR_UPDATE: 'check-for-update',
} as const;
// ---------- Hook ----------
export function useUpdater() {
const [status, setStatus] = useState<UpdaterStatus>('idle');
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [progress, setProgress] = useState<UpdateProgress>({ percent: 0 });
const [error, setError] = useState<UpdateError | null>(null);
// 防止 StrictMode 双重监听
const registered = useRef(false);
useEffect(() => {
if (registered.current) return;
registered.current = true;
safeIpcOn(CH.UPDATE_AVAILABLE, (_event: unknown, ...args: unknown[]) => {
setInfo(args[0] as UpdateInfo);
setStatus('available');
});
safeIpcOn(CH.UPDATE_PROGRESS, (_event: unknown, ...args: unknown[]) => {
setStatus('downloading');
setProgress(args[0] as UpdateProgress);
});
safeIpcOn(CH.UPDATE_DOWNLOADED, () => {
setStatus('downloaded');
});
safeIpcOn(CH.UPDATE_ERROR, (_event: unknown, ...args: unknown[]) => {
setError(args[0] as UpdateError);
setStatus('error');
});
safeIpcOn(CH.UPDATE_NOT_AVAILABLE, () => {
setStatus('no-update');
});
return () => {
safeIpcOff(CH.UPDATE_AVAILABLE, () => {});
safeIpcOff(CH.UPDATE_PROGRESS, () => {});
safeIpcOff(CH.UPDATE_DOWNLOADED, () => {});
safeIpcOff(CH.UPDATE_ERROR, () => {});
safeIpcOff(CH.UPDATE_NOT_AVAILABLE, () => {});
};
}, []);
/** 手动检查更新 */
const checkForUpdates = useCallback(async () => {
setStatus('checking');
setError(null);
try {
await safeIpcInvoke(CH.CHECK_FOR_UPDATE);
} catch (err) {
setStatus('error');
setError({ message: (err as Error).message || '检查更新失败' });
}
}, []);
/** 确认安装更新(退出 → 安装 → 重启) */
const installUpdate = useCallback(() => {
safeIpcSend(CH.INSTALL_UPDATE);
}, []);
/** 重置状态 */
const reset = useCallback(() => {
setStatus('idle');
setInfo(null);
setProgress({ percent: 0 });
setError(null);
}, []);
return {
status,
info,
progress,
error,
checkForUpdates,
installUpdate,
reset,
} as const;
}

30
src/main.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App as AntdApp } from 'antd';
import App from './App';
import { ThemeProvider } from './components/ThemeProvider';
import { AppProvider } from './components/AppProvider';
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
import './theme/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* AppProvider — 全局应用状态(平台/版本/登录态) */}
<AppProvider>
{/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */}
<ThemeProvider>
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
<AntdApp>
<App />
</AntdApp>
</ThemeProvider>
</AppProvider>
</React.StrictMode>,
);
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
import { safeIpcOn } from './utils/ipc';
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
console.log('[Main Process Message]:', message);
});

View File

@@ -0,0 +1,92 @@
// ============================================================
// FormField — 单个表单字段(数据驱动渲染)
// 根据 FieldConfig 自动选择 Input / Input.Password 及附加操作按钮
// ============================================================
import { useCallback } from 'react';
import { Input, Button, Form } from 'antd';
import type { FieldConfig } from '../types';
interface FormFieldProps {
config: FieldConfig;
value: string;
onChange: (name: string, value: string) => void;
errors: string[];
/** 发送短信验证码回调 */
onSendSms?: () => void;
/** 验证码按钮冷却中 */
smsCooldown?: boolean;
/** 冷却剩余秒数 */
smsSecondsLeft?: number;
}
export function FormField({
config,
value,
onChange,
errors,
onSendSms,
smsCooldown,
smsSecondsLeft,
}: FormFieldProps) {
const hasError = errors.length > 0;
const isPassword = config.inputType === 'password';
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(config.name, e.target.value);
},
[config.name, onChange],
);
return (
<Form.Item
label={
<span className="text-xs">
{config.label}
{config.optional && <span className="text-gray-400 ml-1">()</span>}
</span>
}
validateStatus={hasError ? 'error' : undefined}
help={hasError ? errors[0] : undefined}
className="!mb-3"
>
{isPassword ? (
<Input.Password
value={value}
onChange={handleChange}
placeholder={config.placeholder}
size="middle"
allowClear
/>
) : config.extraAction === 'send-sms' ? (
<div className="flex gap-2">
<Input
value={value}
onChange={handleChange}
placeholder={config.placeholder}
size="middle"
className="flex-1"
allowClear
/>
<Button
size="middle"
disabled={smsCooldown}
onClick={onSendSms}
className="shrink-0 text-xs"
>
{smsCooldown ? `${smsSecondsLeft}s` : '发送验证码'}
</Button>
</div>
) : (
<Input
value={value}
onChange={handleChange}
placeholder={config.placeholder}
size="middle"
allowClear
/>
)}
</Form.Item>
);
}

View File

@@ -0,0 +1,216 @@
// ============================================================
// LoginForm — 登录表单
// ============================================================
import { useState, useCallback } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { loginFields } from '../config/login-fields';
import { validateForm } from '../config/validators';
import { LegalTextModal } from '@/components/common/LegalTextModal';
import type { LoginFormValues } from '../types';
const { Text } = Typography;
interface LoginFormProps {
initialUsername?: string;
initialPassword?: string;
initialRemember?: boolean;
initialAutoLogin?: boolean;
submitting?: boolean;
onSubmit: (values: LoginFormValues) => void;
onRememberChange: (checked: boolean) => void;
onAutoLoginChange: (checked: boolean) => void;
}
export function LoginForm({
initialUsername,
initialPassword,
initialRemember = false,
initialAutoLogin = false,
submitting = false,
onSubmit,
onRememberChange,
onAutoLoginChange,
}: LoginFormProps) {
const [username, setUsername] = useState(initialUsername || '');
const [password, setPassword] = useState(initialPassword || '');
const [remember, setRemember] = useState(initialRemember);
const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
const [agreed, setAgreed] = useState(false);
const [errors, setErrors] = useState<Record<string, string[]>>({});
// 协议弹窗
const [legalType, setLegalType] = useState<'service' | 'points' | null>(null);
const clearError = (name: string) =>
setErrors((p) => {
const n = { ...p };
delete n[name];
return n;
});
const handleSubmit = useCallback(() => {
const values = { username, password };
const fieldErrors = validateForm(values, loginFields);
if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors);
return;
}
if (!agreed) {
setErrors({ _agreement: ['请先阅读并同意用户协议'] });
return;
}
setErrors({});
onSubmit({ username, password, remember, autoLogin: remember && autoLogin });
}, [username, password, remember, autoLogin, agreed, onSubmit]);
return (
<>
<div className="space-y-5">
{/* 用户名 */}
<div>
<Input
size="large"
prefix={<UserOutlined className="opacity-40" />}
placeholder="用户名"
value={username}
onChange={(e) => {
setUsername(e.target.value);
clearError('username');
}}
status={errors.username ? 'error' : undefined}
allowClear
/>
{errors.username && (
<Text type="danger" className="text-xs ml-1">
{errors.username[0]}
</Text>
)}
</div>
{/* 密码 */}
<div>
<Input.Password
size="large"
prefix={<LockOutlined className="opacity-40" />}
placeholder="密码"
value={password}
onChange={(e) => {
setPassword(e.target.value);
clearError('password');
}}
status={errors.password ? 'error' : undefined}
allowClear
/>
{errors.password && (
<Text type="danger" className="text-xs ml-1">
{errors.password[0]}
</Text>
)}
</div>
{/* 记住密码 / 自动登录 / 忘记密码 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Checkbox
checked={remember}
onChange={(e) => {
const checked = e.target.checked;
setRemember(checked);
onRememberChange(checked);
// 取消记住密码 → 同时取消自动登录
if (!checked) {
setAutoLogin(false);
onAutoLoginChange(false);
}
}}
className="text-xs"
>
</Checkbox>
<Checkbox
checked={autoLogin}
onChange={(e) => {
const checked = e.target.checked;
setAutoLogin(checked);
onAutoLoginChange(checked);
// 勾选自动登录 → 同时勾选记住密码
if (checked && !remember) {
setRemember(true);
onRememberChange(true);
}
}}
className="text-xs"
>
</Checkbox>
</div>
<a
className="text-xs"
onClick={() => {
/* TODO: 忘记密码流程 */
}}
>
?
</a>
</div>
{/* 用户协议 */}
<div>
<Checkbox
checked={agreed}
onChange={(e) => {
setAgreed(e.target.checked);
clearError('_agreement');
}}
className="text-xs"
>
<a
className="text-xs mx-0.5"
onClick={(e) => {
e.stopPropagation();
setLegalType('service');
}}
>
</a>
<a
className="text-xs mx-0.5"
onClick={(e) => {
e.stopPropagation();
setLegalType('points');
}}
>
</a>
</Checkbox>
{errors._agreement && (
<Text type="danger" className="text-xs block mt-1">
{errors._agreement[0]}
</Text>
)}
</div>
{/* 登录按钮 */}
<Button
type="primary"
block
size="large"
icon={<UserOutlined />}
loading={submitting}
onClick={handleSubmit}
className="h-11! text-base! font-medium! mt-2!"
>
</Button>
</div>
{/* 协议弹窗 */}
{legalType && <LegalTextModal open type={legalType} onClose={() => setLegalType(null)} />}
</>
);
}

View File

@@ -0,0 +1,258 @@
// ============================================================
// RegisterForm — 注册表单(仅个人版)
// ============================================================
import { useState, useCallback, useRef } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd';
import { LegalTextModal } from '@/components/common/LegalTextModal';
import {
UserOutlined,
LockOutlined,
PhoneOutlined,
SafetyCertificateOutlined,
LaptopOutlined,
TagOutlined,
SmileOutlined,
GiftOutlined,
} from '@ant-design/icons';
import { registerFields } from '../config/register-fields';
import { validateForm } from '../config/validators';
import type { RegisterFormValues } from '../types';
const { Text } = Typography;
interface RegisterFormProps {
submitting?: boolean;
onSubmit: (values: RegisterFormValues) => void;
onSendSms?: (phone: string) => void;
}
/** 字段名 → 图标映射 */
const iconMap: Record<string, React.ReactNode> = {
username: <UserOutlined className="opacity-40" />,
password: <LockOutlined className="opacity-40" />,
confirmPassword: <LockOutlined className="opacity-40" />,
phone: <PhoneOutlined className="opacity-40" />,
sms_code: <SafetyCertificateOutlined className="opacity-40" />,
device_id: <LaptopOutlined className="opacity-40" />,
device_label: <TagOutlined className="opacity-40" />,
display_name: <SmileOutlined className="opacity-40" />,
referral_code: <GiftOutlined className="opacity-40" />,
};
export function RegisterForm({ submitting = false, onSubmit, onSendSms }: RegisterFormProps) {
const [values, setValues] = useState<Record<string, string>>({});
const [agreed, setAgreed] = useState(false);
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [legalType, setLegalType] = useState<'service' | 'points' | null>(null);
// 验证码冷却
const [smsCooldown, setSmsCooldown] = useState(false);
const [smsSeconds, setSmsSeconds] = useState(0);
const smsTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// 字段值变更
const handleChange = useCallback((name: string, value: string) => {
setValues((p) => ({ ...p, [name]: value }));
setErrors((p) => {
const n = { ...p };
delete n[name];
return n;
});
}, []);
// 发送验证码
const handleSendSms = useCallback(() => {
const phone = values.phone || '';
const phoneConfig = registerFields.find((f) => f.name === 'phone')!;
const phoneErrors = validateForm({ phone }, [phoneConfig]);
if (Object.keys(phoneErrors).length > 0) {
setErrors((p) => ({ ...p, phone: phoneErrors.phone }));
return;
}
setSmsCooldown(true);
setSmsSeconds(60);
smsTimer.current = setInterval(() => {
setSmsSeconds((prev) => {
if (prev <= 1) {
clearInterval(smsTimer.current!);
setSmsCooldown(false);
return 0;
}
return prev - 1;
});
}, 1000);
onSendSms?.(phone);
}, [values.phone, onSendSms]);
// 跨字段校验
const crossValidators = {
confirmPassword: (val: string) => {
if (!val) return '请再次输入密码';
if (val !== values.password) return '两次密码不一致';
return null;
},
};
// 提交
const handleSubmit = useCallback(() => {
const fieldErrors = validateForm(values, registerFields, crossValidators);
if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors);
return;
}
if (!agreed) {
setErrors({ _agreement: ['请先阅读并同意用户协议'] });
return;
}
setErrors({});
onSubmit(values as unknown as RegisterFormValues);
}, [values, agreed, onSubmit]);
// 渲染单个字段
const renderField = (field: (typeof registerFields)[number]) => {
const val = values[field.name] || '';
const err = errors[field.name];
const isPassword = field.inputType === 'password';
const hasSms = field.extraAction === 'send-sms';
return (
<div key={field.name}>
{hasSms ? (
<div className="flex gap-2">
<Input
size="large"
prefix={iconMap[field.name]}
placeholder={field.placeholder}
value={val}
onChange={(e) => handleChange(field.name, e.target.value)}
status={err ? 'error' : undefined}
className="flex-1"
allowClear
/>
<Button
size="large"
type="primary"
disabled={smsCooldown}
onClick={handleSendSms}
className="text-xs! shrink-0 px-3!"
>
{smsCooldown ? `${smsSeconds}s` : '发送验证码'}
</Button>
</div>
) : isPassword ? (
<Input.Password
size="large"
prefix={iconMap[field.name]}
placeholder={field.placeholder}
value={val}
onChange={(e) => handleChange(field.name, e.target.value)}
status={err ? 'error' : undefined}
allowClear
/>
) : (
<Input
size="large"
prefix={iconMap[field.name]}
placeholder={field.placeholder}
value={val}
onChange={(e) => handleChange(field.name, e.target.value)}
status={err ? 'error' : undefined}
allowClear
/>
)}
{err && (
<Text type="danger" className="text-xs ml-1">
{err[0]}
</Text>
)}
</div>
);
};
const requiredFields = registerFields.filter((f) => !f.optional);
const optionalFields = registerFields.filter((f) => f.optional);
return (
<>
<div className="space-y-4">
{/* 必填字段 */}
{requiredFields.map(renderField)}
{/* 选填字段 */}
{optionalFields.length > 0 && (
<>
<Text type="secondary" className="text-xs block mt-5! mb-1!">
</Text>
{optionalFields.map(renderField)}
</>
)}
{/* 邀请码说明 */}
<Text type="secondary" className="text-xs! block mt-1!">
</Text>
{/* 用户协议 */}
<div className="mt-5!">
<Checkbox
checked={agreed}
onChange={(e) => {
setAgreed(e.target.checked);
setErrors((p) => {
const n = { ...p };
delete n._agreement;
return n;
});
}}
className="text-xs"
>
<a
className="text-xs mx-0.5"
onClick={(e) => {
e.stopPropagation();
setLegalType('service');
}}
>
</a>
<a
className="text-xs mx-0.5"
onClick={(e) => {
e.stopPropagation();
setLegalType('points');
}}
>
</a>
</Checkbox>
{errors._agreement && (
<Text type="danger" className="text-xs block mt-1">
{errors._agreement[0]}
</Text>
)}
</div>
{/* 注册按钮 */}
<Button
type="primary"
block
size="large"
loading={submitting}
onClick={handleSubmit}
className="h-11! text-base! font-medium! mt-2!"
>
</Button>
</div>
{/* 协议弹窗 */}
{legalType && <LegalTextModal open type={legalType} onClose={() => setLegalType(null)} />}
</>
);
}

View File

@@ -0,0 +1,40 @@
// ============================================================
// 登录表单 — 字段配置(纯数据,驱动 UI 渲染)
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
// ============================================================
import type { FieldConfig } from '../types';
export const loginFields: FieldConfig[] = [
{
name: 'username',
label: '用户名',
placeholder: '请输入用户名',
inputType: 'text',
rules: [
{ required: true, message: '请输入用户名' },
{ minLen: 3, message: '用户名至少 3 位' },
{ maxLen: 64, message: '用户名最多 64 位' },
{ pattern: /^[a-zA-Z0-9\-_.]+$/, message: '仅允许字母、数字、-、_、.' },
],
},
{
name: 'password',
label: '密码',
placeholder: '请输入密码',
inputType: 'password',
rules: [
{ required: true, message: '请输入密码' },
{ minLen: 6, message: '密码至少 6 位' },
{ maxLen: 128, message: '密码最多 128 位' },
],
},
];
/** 登录表单初始值 */
export const loginInitialValues = {
username: '',
password: '',
remember: false,
autoLogin: false,
};

View File

@@ -0,0 +1,116 @@
// ============================================================
// 注册表单 — 字段配置(纯数据,驱动 UI 渲染)
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
// ============================================================
import type { FieldConfig } from '../types';
export const registerFields: FieldConfig[] = [
{
name: 'username',
label: '用户名',
placeholder: '3~64位字母/数字/-/_/.',
inputType: 'text',
rules: [
{ required: true, message: '请输入用户名' },
{ minLen: 3, message: '用户名至少 3 位' },
{ maxLen: 64, message: '用户名最多 64 位' },
{ pattern: /^[a-zA-Z0-9\-_.]+$/, message: '仅允许字母、数字、-、_、.' },
],
},
{
name: 'password',
label: '密码',
placeholder: '6~128位',
inputType: 'password',
rules: [
{ required: true, message: '请输入密码' },
{ minLen: 6, message: '密码至少 6 位' },
{ maxLen: 128, message: '密码最多 128 位' },
],
},
{
name: 'confirmPassword',
label: '确认密码',
placeholder: '请再次输入密码',
inputType: 'password',
rules: [{ required: true, message: '请再次输入密码' }],
},
{
name: 'phone',
label: '手机号',
placeholder: '请输入手机号',
inputType: 'tel',
rules: [
{ required: true, message: '请输入手机号' },
{ minLen: 5, message: '手机号至少 5 位' },
{ maxLen: 32, message: '手机号最多 32 位' },
{ pattern: /^\+?[0-9]+$/, message: '手机号格式不正确' },
],
},
{
name: 'sms_code',
label: '短信验证码',
placeholder: '请输入验证码',
inputType: 'text',
rules: [
{ required: true, message: '请输入验证码' },
{ minLen: 4, message: '验证码至少 4 位' },
{ maxLen: 8, message: '验证码最多 8 位' },
{ pattern: /^[0-9]+$/, message: '验证码仅允许数字' },
],
extraAction: 'send-sms',
cooldown: 60,
},
{
name: 'device_id',
label: '设备标识',
placeholder: '网卡MAC地址',
inputType: 'text',
rules: [
{ required: true, message: '请输入设备标识' },
{ minLen: 8, message: '设备标识至少 8 位' },
{ maxLen: 128, message: '设备标识最多 128 位' },
],
},
{
name: 'device_label',
label: '设备标签',
placeholder: '如"办公电脑"(可选)',
inputType: 'text',
optional: true,
rules: [{ maxLen: 64, message: '设备标签最多 64 位' }],
},
{
name: 'display_name',
label: '个人昵称',
placeholder: '如何称呼您(可选)',
inputType: 'text',
optional: true,
rules: [{ maxLen: 32, message: '昵称最多 32 位' }],
},
{
name: 'referral_code',
label: '邀请码',
placeholder: '如有邀请码请填写(可选)',
inputType: 'text',
optional: true,
rules: [
{ maxLen: 32, message: '邀请码最多 32 位' },
{ pattern: /^[a-zA-Z0-9]*$/, message: '仅允许字母和数字' },
],
},
];
/** 注册表单初始值 */
export const registerInitialValues = {
username: '',
password: '',
confirmPassword: '',
phone: '',
sms_code: '',
device_id: '',
device_label: '',
display_name: '',
referral_code: '',
};

View File

@@ -0,0 +1,136 @@
// ============================================================
// 校验函数 — 纯函数,与 UI 层解耦
// 每个校验器返回 string错误信息或 null通过
// ============================================================
type ValidateResult = string | null;
/** 用户名3~64 位,仅允许字母、数字、-、_、. */
export function validateUsername(value: string): ValidateResult {
if (!value || !value.trim()) return '请输入用户名';
if (value.length < 3) return '用户名至少 3 位';
if (value.length > 64) return '用户名最多 64 位';
if (!/^[a-zA-Z0-9\-_.]+$/.test(value)) return '仅允许字母、数字、-、_、.';
return null;
}
/** 密码6~128 位 */
export function validatePassword(value: string): ValidateResult {
if (!value) return '请输入密码';
if (value.length < 6) return '密码至少 6 位';
if (value.length > 128) return '密码最多 128 位';
return null;
}
/** 确认密码:与密码一致 */
export function validateConfirmPassword(value: string, password: string): ValidateResult {
if (!value) return '请再次输入密码';
if (value !== password) return '两次输入的密码不一致';
return null;
}
/** 手机号5~32 位数字(可含 + 前缀) */
export function validatePhone(value: string): ValidateResult {
if (!value) return '请输入手机号';
if (value.length < 5) return '手机号至少 5 位';
if (value.length > 32) return '手机号最多 32 位';
if (!/^\+?[0-9]+$/.test(value)) return '手机号格式不正确';
return null;
}
/** 短信验证码4~8 位数字 */
export function validateSmsCode(value: string): ValidateResult {
if (!value) return '请输入验证码';
if (value.length < 4) return '验证码至少 4 位';
if (value.length > 8) return '验证码最多 8 位';
if (!/^[0-9]+$/.test(value)) return '验证码仅允许数字';
return null;
}
/** 设备 ID8~128 位 */
export function validateDeviceId(value: string): ValidateResult {
if (!value) return '请输入设备标识';
if (value.length < 8) return '设备标识至少 8 位';
if (value.length > 128) return '设备标识最多 128 位';
return null;
}
/** 可选字段:非空时不超过 maxLen */
export function optionalMaxLen(maxLen: number, label: string) {
return (value: string): ValidateResult => {
if (!value) return null;
if (value.length > maxLen) return `${label}最多 ${maxLen}`;
return null;
};
}
/** 邀请码:可选,最多 32 位字母数字 */
export function validateReferralCode(value: string): ValidateResult {
if (!value) return null;
if (value.length > 32) return '邀请码最多 32 位';
if (!/^[a-zA-Z0-9]+$/.test(value)) return '仅允许字母和数字';
return null;
}
/**
* 对一条字段配置执行所有规则校验
* @returns 错误信息数组(空数组表示通过)
*/
export function validateField(value: string, rules: import('../types').FieldRule[]): string[] {
const errors: string[] = [];
for (const rule of rules) {
// required
if (rule.required && (!value || !value.trim())) {
errors.push(rule.message || '此字段为必填');
continue;
}
if (!value) continue; // 非必填且为空 → 跳过后续校验
// minLen
if (rule.minLen && value.length < rule.minLen) {
errors.push(rule.message || `至少 ${rule.minLen}`);
}
// maxLen
if (rule.maxLen && value.length > rule.maxLen) {
errors.push(rule.message || `最多 ${rule.maxLen}`);
}
// pattern
if (rule.pattern && !rule.pattern.test(value)) {
errors.push(rule.message || '格式不正确');
}
}
return errors;
}
/**
* 校验整个表单
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
*/
export function validateForm<T extends Record<string, string>>(
values: T,
fieldConfigs: import('../types').FieldConfig[],
crossValidators?: Record<string, (value: string, allValues: T) => string | null>,
): Record<string, string[]> {
const errors: Record<string, string[]> = {};
for (const config of fieldConfigs) {
const value = values[config.name] || '';
const fieldErrors = validateField(value, config.rules);
if (fieldErrors.length > 0) {
errors[config.name] = fieldErrors;
}
}
// 跨字段校验(如确认密码)
if (crossValidators) {
for (const [name, fn] of Object.entries(crossValidators)) {
const value = values[name] || '';
const err = fn(value, values);
if (err) {
errors[name] = [...(errors[name] || []), err];
}
}
}
return errors;
}

View File

@@ -0,0 +1,131 @@
// ============================================================
// useAuthState — 记住密码 / 自动登录 状态管理
// 持久化到 localStorage下次打开页面自动回填
// ============================================================
import { useState, useCallback } from 'react';
const STORAGE_KEY = 'ele-heixiu-auth';
interface StoredAuth {
/** 上次登录的用户名 */
username: string;
/** 加密后的密码Base64 编码,非安全加密,仅方便回填) */
password: string;
/** 是否记住密码 */
remember: boolean;
/** 是否自动登录 */
autoLogin: boolean;
}
/** 简单 Base64 编解码(非安全加密,仅避免明文存储) */
function encode(str: string): string {
try {
return btoa(decodeURI(encodeURIComponent(str)));
} catch {
return '';
}
}
function decode(str: string): string {
try {
return decodeURIComponent(decodeURI(atob(str)));
} catch {
return '';
}
}
/** 从 localStorage 读取已保存的认证信息 */
function readAuth(): StoredAuth | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
/** 写入 localStorage */
function writeAuth(auth: StoredAuth): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} catch {
/* 静默 */
}
}
/** 清除 localStorage */
function clearAuth(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 静默 */
}
}
// ---------- Hook ----------
export function useAuthState() {
// 初始化:从 localStorage 恢复
const [username, setUsername] = useState(() => readAuth()?.username || '');
const [password, setPassword] = useState(() => {
const auth = readAuth();
return auth?.remember ? decode(auth.password) : '';
});
const [remember, setRemember] = useState(() => readAuth()?.remember || false);
const [autoLogin, setAutoLogin] = useState(() => readAuth()?.autoLogin || false);
// 勾选"记住密码"变化时持久化
const handleRememberChange = useCallback((checked: boolean) => {
setRemember(checked);
if (!checked) {
// 取消记住密码 → 同时取消自动登录
setAutoLogin(false);
clearAuth();
}
}, []);
// 勾选"自动登录"变化时持久化
const handleAutoLoginChange = useCallback((checked: boolean) => {
setAutoLogin(checked);
}, []);
// 登录成功后保存
const saveAuth = useCallback(
(user: string, pass: string) => {
setUsername(user);
if (remember) {
setPassword(pass);
writeAuth({
username: user,
password: encode(pass),
remember,
autoLogin,
});
}
},
[remember, autoLogin],
);
// 清除保存的认证信息
const clearSavedAuth = useCallback(() => {
setUsername('');
setPassword('');
setRemember(false);
setAutoLogin(false);
clearAuth();
}, []);
return {
username,
password,
remember,
autoLogin,
setUsername,
setPassword,
handleRememberChange,
handleAutoLoginChange,
saveAuth,
clearSavedAuth,
};
}

149
src/pages/login/index.tsx Normal file
View File

@@ -0,0 +1,149 @@
// ============================================================
// LoginPage — 登录/注册 Modal 弹层
//
// 行为:
// - 未登录时弹出不可操作主页Modal 遮罩)
// - 可关闭(右上 X关闭后主页可操作但 API 401 时会再次弹出
// - 登录成功 → 关闭 Modal更新全局登录态
// ============================================================
import { useState, useCallback } from 'react';
import { Modal, Tabs, Typography, App } from 'antd';
import { useAppContext } from '@/contexts/app-context.ts';
import { useTheme } from '@/hooks/use-theme.ts';
import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm';
import { useAuthState } from './hooks/use-auth-state';
import type { LoginFormValues, RegisterFormValues } from './types';
const { Text } = Typography;
interface LoginPageProps {
open: boolean;
onClose: () => void;
}
export function LoginPage({ open, onClose }: LoginPageProps) {
const { edition, login } = useAppContext();
const { isDark } = useTheme();
const { message } = App.useApp();
const isEnterprise = edition === 'enterprise';
const [activeTab, setActiveTab] = useState<string>('login');
const [submitting, setSubmitting] = useState(false);
const authState = useAuthState();
// ========== 登录 ==========
const handleLogin = useCallback(
async (values: LoginFormValues) => {
setSubmitting(true);
try {
// TODO: 接入登录 API
console.log('[Login]', values);
authState.saveAuth(values.username, values.password);
message.success('登录成功Demo');
login();
onClose();
} catch (err) {
message.error((err as Error).message || '登录失败');
} finally {
setSubmitting(false);
}
},
[authState, message, login, onClose],
);
// ========== 注册 ==========
const handleRegister = useCallback(
async (values: RegisterFormValues) => {
setSubmitting(true);
try {
console.log('[Register]', values);
message.success('注册成功Demo请登录');
setActiveTab('login');
} catch (err) {
message.error((err as Error).message || '注册失败');
} finally {
setSubmitting(false);
}
},
[message],
);
const handleSendSms = useCallback(
(phone: string) => {
console.log('[SMS]', phone);
message.info(`验证码已发送到 ${phone}Demo`);
},
[message],
);
// ========== 渲染 ==========
const loginTab = (
<LoginForm
initialUsername={authState.username}
initialPassword={authState.password}
initialRemember={authState.remember}
initialAutoLogin={authState.autoLogin}
submitting={submitting}
onSubmit={handleLogin}
onRememberChange={authState.handleRememberChange}
onAutoLoginChange={authState.handleAutoLoginChange}
/>
);
const registerTab = (
<RegisterForm submitting={submitting} onSubmit={handleRegister} onSendSms={handleSendSms} />
);
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={440}
centered
destroyOnHidden={false}
mask={{
closable: false,
}}
styles={{
body: {
padding: '24px 32px 16px',
background: isDark ? '#1A1730' : '#FFFFFF',
},
}}
>
{/* 标题 */}
<div className="text-center mb-5">
<Text strong className="text-lg gradient-primary-text block">
· HeiXiu
</Text>
<Text type="secondary" className="text-xs">
{isEnterprise ? '企业版 — 员工账号登录' : '个人版'}
</Text>
</div>
{/* 个人版 Tab | 企业版仅登录 */}
{isEnterprise ? (
loginTab
) : (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
centered
size="small"
items={[
{ key: 'login', label: '登录', children: loginTab },
{ key: 'register', label: '注册', children: registerTab },
]}
/>
)}
</Modal>
);
}

52
src/pages/login/types.ts Normal file
View File

@@ -0,0 +1,52 @@
// ============================================================
// 登录/注册 — 类型定义
// ============================================================
// ---------- 登录 ----------
export interface LoginFormValues {
username: string;
password: string;
remember: boolean;
autoLogin: boolean;
}
// ---------- 注册(个人版) ----------
export interface RegisterFormValues {
username: string;
password: string;
confirmPassword: string;
phone: string;
sms_code: string;
device_id: string;
device_label: string;
display_name: string;
referral_code: string;
}
// ---------- 字段配置(数据驱动 UI 渲染) ----------
export interface FieldRule {
required?: boolean;
minLen?: number;
maxLen?: number;
pattern?: RegExp;
message?: string;
}
export type InputType = 'text' | 'password' | 'tel';
export interface FieldConfig {
name: string;
label: string;
placeholder: string;
inputType: InputType;
rules: FieldRule[];
/** 仅注册表单的选填字段 */
optional?: boolean;
/** 附带操作按钮标识 */
extraAction?: 'send-sms';
/** 冷却秒数(发送验证码按钮用) */
cooldown?: number;
}

View File

@@ -0,0 +1,38 @@
// ============================================================
// 公告模块 — 类型定义 + API 请求
// GET /api/v1/announcements
// ============================================================
import { get } from '../request';
// ---------- 类型 ----------
/** 公告类型枚举 */
export type AnnouncementType = 'system' | 'feature' | 'activity' | 'maintenance' | string;
/** 单条公告 */
export interface Announcement {
id: number;
title: string;
content: string;
type: AnnouncementType;
is_active: boolean;
pinned: boolean;
expires_at: string;
created_by: string;
created_at: string;
updated_at: string;
}
/** 公告列表响应 */
export type AnnouncementListResponse = Announcement[];
// ---------- API ----------
/**
* 获取公告列表
* GET /api/v1/announcements
*/
export function fetchAnnouncements(): Promise<AnnouncementListResponse> {
return get<AnnouncementListResponse>('/api/v1/announcements');
}

View File

@@ -0,0 +1,11 @@
// ============================================================
// API 模块统一导出
// 后续新增模块(如 auth、user、billing 等)在此追加
// ============================================================
export {
fetchAnnouncements,
type Announcement,
type AnnouncementType,
type AnnouncementListResponse,
} from './announcement';

285
src/services/request.ts Normal file
View File

@@ -0,0 +1,285 @@
// ============================================================
// Axios 请求封装
//
// 功能:
// - 统一 BaseURL环境变量 VITE_API_BASE_URL
// - 请求拦截器:自动附加 Token
// - 响应拦截器:统一错误处理、业务码校验
// - 类型安全的 GET / POST / PUT / DELETE / UPLOAD
// - 请求超时、取消请求支持
// ============================================================
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
import type { ApiResponse } from './types';
import { RequestError, RequestErrorType } from './types';
import { emit, EVENTS } from '../utils/event-bus';
// ---------- 配置 ----------
/** API 基础地址(可通过 Vite 环境变量覆盖) */
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
/** 请求超时时间(毫秒) */
const TIMEOUT = 30000;
/** localStorage 中 Token 的键名 */
const TOKEN_KEY = 'ele-heixiu-token';
// ---------- Token 管理 ----------
/** 获取存储的 Token */
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
/** 设置 Token */
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* 静默忽略 */
}
}
/** 清除 Token */
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* 静默忽略 */
}
}
// ---------- Axios 实例 ----------
const instance: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
// ---------- 请求拦截器 ----------
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 自动附加 Token
const token = getToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
() => {
return Promise.reject(new RequestError(RequestErrorType.NETWORK, '请求配置失败'));
},
);
// ---------- 响应拦截器 ----------
instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response;
// HTTP 200 但业务码非 0 → 业务错误
if (data && data.code !== 0) {
const err = new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
code: data.code,
traceId: data.traceId,
});
// 特殊业务码处理
if (data.code === 401) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
}
return Promise.reject(err);
}
return response;
},
(error) => {
// 请求已被取消
if (axios.isCancel(error)) {
return Promise.reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
}
// 超时
if (error.code === 'ECONNABORTED') {
return Promise.reject(new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络'));
}
// 无网络
if (!error.response) {
return Promise.reject(new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接'));
}
// HTTP 状态码错误
const { status, data } = error.response;
let errMsg = '请求失败';
switch (status) {
case 400:
errMsg = '请求参数有误';
break;
case 401:
errMsg = '登录已过期,请重新登录';
clearToken();
emit(EVENTS.AUTH_REQUIRED);
break;
case 403:
errMsg = '没有访问权限';
break;
case 404:
errMsg = '请求的资源不存在';
break;
case 500:
errMsg = '服务器内部错误';
break;
case 502:
errMsg = '网关错误';
break;
case 503:
errMsg = '服务暂时不可用';
break;
}
return Promise.reject(
new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
httpStatus: status,
code: data?.code,
}),
);
},
);
// ---------- 请求方法 ----------
/**
* 发起 GET 请求
* @param url - 请求路径
* @param params - 查询参数
* @param config - Axios 配置覆盖
*/
export async function get<T>(
url: string,
params?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<T> {
const res = await instance.get<ApiResponse<T>>(url, { ...config, params });
return res.data.data;
}
/**
* 发起 POST 请求JSON
* @param url - 请求路径
* @param data - 请求体
* @param config - Axios 配置覆盖
*/
export async function post<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const res = await instance.post<ApiResponse<T>>(url, data, config);
return res.data.data;
}
/**
* 发起 PUT 请求JSON
*/
export async function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const res = await instance.put<ApiResponse<T>>(url, data, config);
return res.data.data;
}
/**
* 发起 DELETE 请求
*/
export async function del<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const res = await instance.delete<ApiResponse<T>>(url, config);
return res.data.data;
}
/**
* 文件上传multipart/form-data
* @param url - 上传接口路径
* @param formData - FormData 对象
* @param onProgress - 上传进度回调 (0~100)
*/
export async function upload<T>(
url: string,
formData: FormData,
onProgress?: (percent: number) => void,
): Promise<T> {
const res = await instance.post<ApiResponse<T>>(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000, // 上传超时 2 分钟
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
});
return res.data.data;
}
/**
* 文件下载
* @param url - 下载接口路径
* @param filename - 保存的文件名
*/
export async function download(url: string, filename?: string): Promise<void> {
const res = await instance.get(url, {
responseType: 'blob',
});
const blob = new Blob([res.data]);
const downloadUrl = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.download = filename || 'download';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(downloadUrl);
}
/**
* 创建可取消的请求控制器
* 用法:
* const { controller, request } = createCancellableRequest();
* request(get('/some/url')); // 发起请求
* controller.abort(); // 取消请求
*/
export function createCancellableRequest() {
const controller = new AbortController();
return {
controller,
signal: controller.signal,
/** 包裹一个请求 Promise使其可通过 controller.abort() 取消 */
request: <T>(promise: Promise<T>): Promise<T> => {
return new Promise<T>((resolve, reject) => {
promise.then(resolve).catch(reject);
controller.signal.addEventListener('abort', () => {
reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
});
});
},
};
}
// ---------- 导出 axios 实例(供高级场景直接使用) ----------
export { instance as axiosInstance };
export { RequestError, RequestErrorType };

71
src/services/types.ts Normal file
View File

@@ -0,0 +1,71 @@
// ============================================================
// API 响应通用类型
// ============================================================
/** 后端统一响应结构 */
export interface ApiResponse<T = unknown> {
/** 业务状态码0 表示成功 */
code: number;
/** 响应数据 */
data: T;
/** 提示信息 */
message: string;
/** 请求追踪 ID可选用于排查问题 */
traceId?: string;
}
/** 分页请求参数 */
export interface PaginationParams {
page: number;
pageSize: number;
}
/** 分页响应数据 */
export interface PaginatedData<T> {
/** 数据列表 */
list: T[];
/** 总条数 */
total: number;
/** 当前页码 */
page: number;
/** 每页条数 */
pageSize: number;
}
/** 分页响应包装 */
export type PaginatedResponse<T> = ApiResponse<PaginatedData<T>>;
/** 请求错误类型 */
export enum RequestErrorType {
/** 网络错误(断网等) */
NETWORK = 'NETWORK',
/** 请求超时 */
TIMEOUT = 'TIMEOUT',
/** HTTP 状态码异常4xx / 5xx */
HTTP = 'HTTP',
/** 业务错误code ≠ 0 */
BUSINESS = 'BUSINESS',
/** 请求被取消 */
CANCELLED = 'CANCELLED',
}
/** 请求错误 */
export class RequestError extends Error {
type: RequestErrorType;
code?: number;
httpStatus?: number;
traceId?: string;
constructor(
type: RequestErrorType,
message: string,
options?: { code?: number; httpStatus?: number; traceId?: string },
) {
super(message);
this.name = 'RequestError';
this.type = type;
this.code = options?.code;
this.httpStatus = options?.httpStatus;
this.traceId = options?.traceId;
}
}

312
src/theme/antd-theme.ts Normal file
View File

@@ -0,0 +1,312 @@
// ============================================================
// Ant Design 主题适配器 —— 蓝紫渐变主题(亮色 + 暗色)
// 通过 ConfigProvider 的 theme 属性覆盖 Ant Design 默认主题变量,
// 确保与 Tailwind 主题令牌tokens.ts / globals.css同步。
// ============================================================
import type { ThemeConfig } from 'antd';
import { theme } from 'antd';
import {
colorPrimary,
colorPrimaryLight,
colorSuccess,
colorWarning,
colorError,
colorInfo,
colorTextBase,
colorTextSecondary,
colorBgBase,
colorBgContainer,
colorBgElevated,
colorBgLayout,
colorBorder,
colorBorderSecondary,
borderRadiusBase,
borderRadiusLG,
borderRadiusSM,
borderRadiusXS,
fontFamily,
fontFamilyCode,
fontSizeBase,
fontSizeLG,
fontSizeSM,
fontSizeXL,
boxShadowBase,
boxShadowLG,
gradientPrimary,
// 暗色令牌
darkColorTextBase,
darkColorTextSecondary,
darkColorTextTertiary,
darkColorBgBase,
darkColorBgContainer,
darkColorBgElevated,
darkColorBgLayout,
darkColorBorder,
darkColorBorderSecondary,
} from './tokens';
// ============================================================
// 共享的组件级覆盖(亮色 & 暗色通用)
// ============================================================
const sharedComponents: ThemeConfig['components'] = {
Button: {
colorPrimary,
colorPrimaryHover: colorPrimaryLight,
colorPrimaryActive: '#4F46E5',
primaryShadow: '0 2px 0 rgba(99, 102, 241, 0.15)',
borderRadius: borderRadiusBase,
defaultBorderColor: colorBorder,
},
Menu: {
itemSelectedBg: '#EEF2FF',
itemSelectedColor: colorPrimary,
itemHoverBg: '#F5F3FF',
itemActiveBg: '#E0E7FF',
},
Tabs: {
itemSelectedColor: colorPrimary,
itemHoverColor: colorPrimaryLight,
inkBarColor: colorPrimary,
},
Modal: {
borderRadiusLG,
},
Card: {
borderRadiusLG,
},
Tag: {
defaultBg: '#EEF2FF',
defaultColor: colorPrimary,
},
Input: {
activeBorderColor: colorPrimary,
hoverBorderColor: colorPrimaryLight,
},
Select: {
activeBorderColor: colorPrimary,
hoverBorderColor: colorPrimaryLight,
},
DatePicker: {
activeBorderColor: colorPrimary,
hoverBorderColor: colorPrimaryLight,
},
Switch: {
colorPrimary,
colorPrimaryHover: colorPrimaryLight,
},
Slider: {
colorPrimary,
colorPrimaryBorder: colorPrimary,
colorPrimaryBorderHover: colorPrimaryLight,
},
Progress: {
defaultColor: colorPrimary,
},
Spin: {
colorPrimary,
},
Breadcrumb: {
linkColor: colorPrimary,
linkHoverColor: colorPrimaryLight,
lastItemColor: colorTextBase,
},
Pagination: {
itemActiveBg: colorPrimary,
colorPrimary,
colorPrimaryHover: colorPrimaryLight,
},
Notification: {
borderRadiusLG,
},
Tooltip: {
borderRadius: borderRadiusSM,
},
};
// ============================================================
// 共享的 token 基座(不变部分)
// ============================================================
const sharedTokens = {
// 品牌色
colorPrimary,
colorPrimaryBg: '#EEF2FF',
colorPrimaryBgHover: '#E0E7FF',
colorPrimaryBorder: '#C7D2FE',
colorPrimaryBorderHover: '#A5B4FC',
colorPrimaryHover: colorPrimaryLight,
colorPrimaryActive: '#4F46E5',
colorPrimaryTextHover: colorPrimaryLight,
colorPrimaryText: colorPrimary,
colorPrimaryTextActive: '#4338CA',
// 功能色
colorSuccess,
colorWarning,
colorError,
colorInfo,
// 圆角
borderRadius: borderRadiusBase,
borderRadiusLG,
borderRadiusSM,
borderRadiusXS,
// 字体
fontFamily,
fontFamilyCode,
fontSize: fontSizeBase,
fontSizeLG,
fontSizeSM,
fontSizeXL,
fontSizeHeading1: 28,
fontSizeHeading2: 24,
fontSizeHeading3: 20,
fontSizeHeading4: 18,
fontSizeHeading5: 16,
// 间距
padding: 16,
paddingLG: 24,
paddingSM: 12,
paddingXS: 8,
paddingXXS: 4,
// 链接
colorLink: colorPrimary,
colorLinkHover: colorPrimaryLight,
colorLinkActive: '#4F46E5',
};
// ============================================================
// 🌞 亮色主题配置
// ============================================================
export const lightTheme: ThemeConfig = {
algorithm: theme.defaultAlgorithm,
token: {
...sharedTokens,
// 文字色 — 亮色
colorText: colorTextBase,
colorTextSecondary,
colorTextTertiary: '#9CA3AF',
// 背景色 — 亮色
colorBgBase,
colorBgContainer,
colorBgElevated,
colorBgLayout,
// 边框 — 亮色
colorBorder,
colorBorderSecondary,
// 阴影
boxShadow: boxShadowBase,
boxShadowSecondary: boxShadowLG,
},
components: {
...sharedComponents,
Layout: {
siderBg: `linear-gradient(180deg, #1E1B4B 0%, #312E81 100%)`,
headerBg: colorBgBase,
bodyBg: colorBgLayout,
},
Table: {
headerBg: '#EEF2FF',
headerColor: colorTextBase,
rowHoverBg: '#F5F3FF',
borderColor: colorBorderSecondary,
},
},
};
// ============================================================
// 🌙 暗色主题配置
// ============================================================
export const darkTheme: ThemeConfig = {
algorithm: theme.darkAlgorithm,
token: {
...sharedTokens,
// 暗色模式下品牌色适当提亮以保证对比度
colorPrimary: colorPrimaryLight,
colorPrimaryHover: '#A5B4FC',
colorPrimaryActive: colorPrimary,
colorPrimaryBg: '#1E1B4B',
colorPrimaryBgHover: '#312E81',
colorPrimaryBorder: '#4338CA',
colorPrimaryBorderHover: colorPrimary,
colorPrimaryTextHover: '#A5B4FC',
colorPrimaryText: colorPrimaryLight,
colorPrimaryTextActive: colorPrimary,
// 链接
colorLink: colorPrimaryLight,
colorLinkHover: '#A5B4FC',
colorLinkActive: colorPrimary,
// 文字色 — 暗色
colorText: darkColorTextBase,
colorTextSecondary: darkColorTextSecondary,
colorTextTertiary: darkColorTextTertiary,
// 背景色 — 暗色
colorBgBase: darkColorBgBase,
colorBgContainer: darkColorBgContainer,
colorBgElevated: darkColorBgElevated,
colorBgLayout: darkColorBgLayout,
// 边框 — 暗色
colorBorder: darkColorBorder,
colorBorderSecondary: darkColorBorderSecondary,
// 阴影 — 暗色模式下用更深的投影
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3)',
boxShadowSecondary: '0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4)',
},
components: {
...sharedComponents,
Button: {
...sharedComponents?.Button,
defaultBorderColor: darkColorBorder,
},
Menu: {
itemSelectedBg: '#312E81',
itemSelectedColor: colorPrimaryLight,
itemHoverBg: '#242040',
itemActiveBg: '#1E1B4B',
},
Layout: {
siderBg: `linear-gradient(180deg, #0B0A15 0%, #1A1730 100%)`,
headerBg: darkColorBgContainer,
bodyBg: darkColorBgLayout,
},
Table: {
headerBg: '#1E1B4B',
headerColor: darkColorTextBase,
rowHoverBg: '#242040',
borderColor: darkColorBorderSecondary,
},
Tag: {
defaultBg: '#1E1B4B',
defaultColor: colorPrimaryLight,
},
},
};
// ============================================================
// 辅助导出
// ============================================================
/** 根据 isDark 获取对应的主题配置 */
export function getThemeConfig(isDark: boolean): ThemeConfig {
return isDark ? darkTheme : lightTheme;
}
export { gradientPrimary };

257
src/theme/globals.css Normal file
View File

@@ -0,0 +1,257 @@
/* ============================================================
* 全局样式 —— Tailwind CSS v4 指令 + 蓝紫渐变主题扩展 + 重置样式
* Tailwind v4 使用 CSS @theme 指令替代 tailwind.config.ts
* 设计令牌需与 tokens.ts / antd-theme.ts 保持同步。
* ============================================================ */
@import 'tailwindcss';
/* ============================================================
* Tailwind CSS v4 主题扩展(用 @theme 指令)
* 此处定义的自定义令牌会自动生成对应的 Tailwind 工具类。
* 例如colors.primary → bg-primary, text-primary, border-primary 等。
* ============================================================ */
/* 品牌色 — 蓝紫渐变 */
@theme {
/* 主色系 */
--color-primary: #6366f1; /* Indigo-500 */
--color-primary-light: #818cf8; /* Indigo-400 */
--color-primary-dark: #4f46e5; /* Indigo-600 */
--color-primary-50: #eef2ff; /* Indigo-50 */
--color-primary-100: #e0e7ff; /* Indigo-100 */
--color-primary-200: #c7d2fe; /* Indigo-200 */
--color-primary-300: #a5b4fc; /* Indigo-300 */
--color-primary-400: #818cf8; /* Indigo-400 */
--color-primary-500: #6366f1; /* Indigo-500 */
--color-primary-600: #4f46e5; /* Indigo-600 */
--color-primary-700: #4338ca; /* Indigo-700 */
--color-primary-800: #3730a3; /* Indigo-800 */
--color-primary-900: #312e81; /* Indigo-900 */
/* 辅助色系 — Violet */
--color-secondary: #8b5cf6; /* Violet-500 */
--color-secondary-light: #a78bfa; /* Violet-400 */
--color-secondary-dark: #7c3aed; /* Violet-600 */
/* 功能色 */
--color-success: #10b981;
--color-success-light: #d1fae5;
--color-warning: #f59e0b;
--color-warning-light: #fef3c7;
--color-error: #ef4444;
--color-error-light: #fee2e2;
--color-info: #3b82f6;
--color-info-light: #dbeafe;
/* 圆角 */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-base: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
/* 字体 */
--font-family-base:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-family-mono:
'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace;
/* 动画 */
--transition-duration: 0.2s;
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
* 🌞 亮色模式 CSS 变量(默认)
* 这些变量与 tokens.ts 中的亮色令牌同步
* ============================================================ */
:root,
[data-theme='light'] {
/* 中性色 — 亮色 */
--color-text-base: #1e1b4b;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-bg-base: #ffffff;
--color-bg-container: #f9fafb;
--color-bg-layout: #f5f3ff;
--color-bg-elevated: #ffffff;
--color-border-base: #e5e7eb;
--color-border-secondary: #f3f4f6;
/* 阴影 */
--shadow-base: 0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1);
}
/* ============================================================
* 🌙 暗色模式 CSS 变量
* 当 <html data-theme="dark"> 时自动切换
* 这些变量与 tokens.ts 中的暗色令牌同步
* ============================================================ */
[data-theme='dark'] {
/* 中性色 — 暗色 */
--color-text-base: #e8e6f0;
--color-text-secondary: #a5a0c9;
--color-text-tertiary: #6e6998;
--color-bg-base: #0f0d1a;
--color-bg-container: #1a1730;
--color-bg-layout: #0b0a15;
--color-bg-elevated: #242040;
--color-border-base: #2e2a4a;
--color-border-secondary: #1f1c38;
/* 暗色模式下品牌色提亮以保持对比度 */
--color-primary: #818cf8;
--color-primary-light: #a5b4fc;
--color-primary-dark: #6366f1;
/* 阴影 — 暗色下用深投影 */
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
/* ============================================================
* 蓝紫渐变工具类
* Tailwind v4 使用 @utility 定义自定义工具类
* ============================================================ */
@utility gradient-primary {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
}
@utility gradient-primary-hover {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
@utility gradient-primary-text {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-border {
border-image: linear-gradient(135deg, #4f46e5, #7c3aed) 1;
}
/* ============================================================
* 全局重置样式
* 仅放必要的重置,不堆积组件样式
* ============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: var(--font-family-base);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* 亮/暗切换过渡 */
color-scheme: light dark;
}
body {
margin: 0;
padding: 0;
color: var(--color-text-base);
background-color: var(--color-bg-base);
min-height: 100vh;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
#root {
min-height: 100vh;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c7d2fe;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a5b4fc;
}
/* 暗色模式滚动条 */
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: #4338ca;
}
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #4f46e5;
}
/* 聚焦环 — 使用品牌色 */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* 选中文本 — 使用品牌色 */
::selection {
background: rgba(99, 102, 241, 0.2);
color: var(--color-text-base);
}
[data-theme='dark'] ::selection {
background: rgba(129, 140, 248, 0.3);
color: var(--color-text-base);
}
/* 链接全局样式 */
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-duration) var(--transition-timing);
}
a:hover {
color: var(--color-primary-light);
}
/* ============================================================
* 窗口拖拽区域(自定义导航栏)
*
* macOS hiddenInset 模式下,整个导航栏区域可拖拽移动窗口。
* .navbar-drag-region → 可拖拽(背景区域)
* .navbar-no-drag → 不可拖拽(按钮、链接等交互元素)
* ============================================================ */
.navbar-drag-region {
-webkit-app-region: drag;
user-select: none;
}
.navbar-no-drag {
-webkit-app-region: no-drag;
}
/* macOS 红绿灯按钮区域不遮挡内容 */
@supports (-webkit-app-region: drag) {
.navbar-drag-region {
-webkit-app-region: drag;
}
}

117
src/theme/tokens.ts Normal file
View File

@@ -0,0 +1,117 @@
// ============================================================
// 设计令牌Design Tokens—— 蓝紫渐变主题
// 此处定义的颜色、圆角、阴影等令牌,需同步到:
// 1. globals.cssTailwind CSS 扩展,通过 @theme 指令)
// 2. antd-theme.tsAnt Design ConfigProvider 主题配置)
// 修改时三处同步更新。
// ============================================================
// ---------- 品牌色(蓝紫渐变) ----------
/** 品牌主色 — Indigo-500 */
export const colorPrimary = '#6366F1';
/** 品牌主色浅色变体 */
export const colorPrimaryLight = '#818CF8';
/** 品牌主色深色变体 */
export const colorPrimaryDark = '#4F46E5';
/** 品牌辅助色 — Violet-500渐变另一端 */
export const colorSecondary = '#8B5CF6';
/** 品牌渐变色起始值 */
export const gradientStart = '#4F46E5'; // Indigo-600
/** 品牌渐变色结束值 */
export const gradientEnd = '#7C3AED'; // Violet-600
/** 品牌渐变色CSS 值,可直接用于 background */
export const gradientPrimary = `linear-gradient(135deg, ${gradientStart}, ${gradientEnd})`;
/** 品牌渐变悬停态 */
export const gradientPrimaryHover = `linear-gradient(135deg, ${colorPrimary}, ${colorSecondary})`;
// ---------- 功能色 ----------
export const colorSuccess = '#10B981';
export const colorWarning = '#F59E0B';
export const colorError = '#EF4444';
export const colorInfo = '#3B82F6';
// ---------- 中性色(亮色模式) ----------
export const colorTextBase = '#1E1B4B'; // 深蓝紫黑
export const colorTextSecondary = '#6B7280';
export const colorTextTertiary = '#9CA3AF';
export const colorBgBase = '#FFFFFF';
export const colorBgContainer = '#F9FAFB';
export const colorBgElevated = '#FFFFFF';
export const colorBgLayout = '#F5F3FF'; // Violet-50
export const colorBgSpotlight = '#F3F4F6';
export const colorBorder = '#E5E7EB';
export const colorBorderSecondary = '#F3F4F6';
// ---------- 中性色(暗色模式) ----------
/** 暗色模式 — 主文字 */
export const darkColorTextBase = '#E8E6F0';
/** 暗色模式 — 次要文字 */
export const darkColorTextSecondary = '#A5A0C9';
/** 暗色模式 — 辅助文字 */
export const darkColorTextTertiary = '#6E6998';
/** 暗色模式 — 基础背景 */
export const darkColorBgBase = '#0F0D1A'; // 深蓝紫黑
/** 暗色模式 — 容器背景 */
export const darkColorBgContainer = '#1A1730'; // 抬升一层
/** 暗色模式 — 悬浮背景 */
export const darkColorBgElevated = '#242040'; // 再抬升一层
/** 暗色模式 — 布局背景 */
export const darkColorBgLayout = '#0B0A15'; // 最深背景
/** 暗色模式 — 边框 */
export const darkColorBorder = '#2E2A4A';
/** 暗色模式 — 次要边框 */
export const darkColorBorderSecondary = '#1F1C38';
// ---------- 圆角 ----------
export const borderRadiusBase = 8;
export const borderRadiusLG = 12;
export const borderRadiusSM = 6;
export const borderRadiusXS = 4;
// ---------- 阴影 ----------
export const boxShadowBase =
'0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08)';
export const boxShadowLG =
'0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1)';
// ---------- 字体 ----------
export const fontFamily = `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft YaHei', sans-serif`;
export const fontFamilyCode = `'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace`;
export const fontSizeBase = 14;
export const fontSizeLG = 16;
export const fontSizeSM = 12;
export const fontSizeXL = 20;
// ---------- 间距 ----------
export const spacingUnit = 4;
export const spacingXS = 4;
export const spacingSM = 8;
export const spacingMD = 16;
export const spacingLG = 24;
export const spacingXL = 32;
export const spacingXXL = 48;
// ---------- 动画 ----------
export const transitionDuration = '0.2s';
export const transitionTimingFunction = 'cubic-bezier(0.4, 0, 0.2, 1)';

47
src/utils/event-bus.ts Normal file
View File

@@ -0,0 +1,47 @@
// ============================================================
// 事件总线 — 跨组件/跨模块事件通信
// 用于 axios 拦截器 → App 触发登录弹窗等解耦场景
// ============================================================
type Listener = (...args: unknown[]) => void;
const listeners = new Map<string, Set<Listener>>();
/** 监听事件 */
export function on(event: string, fn: Listener): void {
if (!listeners.has(event)) {
listeners.set(event, new Set());
}
listeners.get(event)!.add(fn);
}
/** 取消监听 */
export function off(event: string, fn: Listener): void {
listeners.get(event)?.delete(fn);
}
/** 触发事件 */
export function emit(event: string, ...args: unknown[]): void {
listeners.get(event)?.forEach((fn) => {
try {
fn(...args);
} catch (e) {
console.error(`[EventBus] ${event} 回调出错:`, e);
}
});
}
// ============================================================
// 预定义事件名
// ============================================================
export const EVENTS = {
/** 需要登录axios 返回 401 时触发) */
AUTH_REQUIRED: 'auth:required',
/** 显示登录弹窗(用户点击登录按钮触发) */
SHOW_LOGIN: 'auth:show-login',
/** 登录成功 */
LOGIN_SUCCESS: 'auth:login-success',
/** 退出登录 */
LOGOUT: 'auth:logout',
} as const;

43
src/utils/ipc.ts Normal file
View File

@@ -0,0 +1,43 @@
// ============================================================
// IPC 安全访问守卫
// window.ipcRenderer 仅在 Electron 环境中存在(由 preload 注入),
// 浏览器环境下为 undefined所有调用必须先行判断。
// ============================================================
/** 检查是否在 Electron 环境中window.ipcRenderer 可用) */
export function hasIpc(): boolean {
return typeof window !== 'undefined' && window.ipcRenderer != null;
}
/** 安全地监听 IPC 消息 */
export function safeIpcOn(
channel: string,
listener: (event: unknown, ...args: unknown[]) => void,
): void {
if (hasIpc()) {
window.ipcRenderer.on(channel, listener);
}
}
/** 安全地取消监听 IPC 消息 */
export function safeIpcOff(channel: string, listener: (...args: unknown[]) => void): void {
if (hasIpc()) {
window.ipcRenderer.off(channel, listener);
}
}
/** 安全地发送 IPC 消息(不等待响应) */
export function safeIpcSend(channel: string, ...args: unknown[]): void {
if (hasIpc()) {
window.ipcRenderer.send(channel, ...args);
}
}
/** 安全地调用 IPC等待响应 */
export async function safeIpcInvoke(channel: string, ...args: unknown[]): Promise<unknown> {
if (!hasIpc()) {
console.warn(`[IPC] 非 Electron 环境invoke "${channel}" 被忽略`);
return undefined;
}
return window.ipcRenderer.invoke(channel, ...args);
}

53
src/utils/logo.ts Normal file
View File

@@ -0,0 +1,53 @@
// ============================================================
// 渲染进程 Logo 自适应工具
// 提供按平台返回正确 Logo 资源路径的函数,
// 用于 <img src> 等场景。
//
// 资源存放在 src/assets/logo/
// heixiu.ico — Windows 多尺寸图标
// HX.png — 通用 PNG Logo
// HX2.png — 备用 PNG Logo方形变体
// ============================================================
import { isMacOS, isWindows } from './platform';
// Vite 中直接 import 图片资源得到编译后的 URL
import hxPng from '../assets/logo/HX.png';
import hx2Png from '../assets/logo/HX2.png';
// .ico 文件通常不太适合 <img> 标签,但在 Electron 窗口图标中使用
/**
* 获取主 Logo 图片 URL用于界面展示、关于对话框等
* 所有平台统一使用 PNG
*/
export function getMainLogoUrl(): string {
return hxPng;
}
/**
* 获取备用/方形 Logo 图片 URL
*/
export function getAltLogoUrl(): string {
return hx2Png;
}
/**
* 获取浏览器 favicon 路径
* Windows 可用 .ico其他平台用 .png
*/
export function getFaviconPath(): string {
if (isWindows()) {
return '/src/assets/logo/heixiu.ico';
}
return hxPng;
}
/**
* 获取平台推荐的应用图标格式说明
* 用于调试或信息展示
*/
export function getPlatformIconFormatInfo(): string {
if (isMacOS()) return '.icns当前使用 .png 回退)';
if (isWindows()) return '.ico';
return '.png';
}

89
src/utils/platform.ts Normal file
View File

@@ -0,0 +1,89 @@
// ============================================================
// 渲染进程平台判定工具
// 通过 preload 暴露的 process.platform 或 navigator.userAgent 判定
// ============================================================
import type { Platform } from '@shared/types';
/**
* 获取当前平台标识
* 优先使用 preload 暴露的 Electron process.platform
* 降级使用 navigator.userAgent 推断
*/
export function getPlatform(): Platform {
// 如果 preload 暴露了 platform 信息,优先使用
if (typeof window !== 'undefined' && window.ipcRenderer) {
// 通过 IPC 或 User-Agent 判断
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('mac os') || ua.includes('macintosh')) return 'darwin';
if (ua.includes('windows') || ua.includes('win32') || ua.includes('win64')) return 'win32';
if (ua.includes('linux')) return 'linux';
}
// 降级方案:基于 userAgent 判断
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('mac')) return 'darwin';
if (ua.includes('win')) return 'win32';
if (ua.includes('linux')) return 'linux';
// 默认为 Windows
return 'win32';
}
/** 是否为 macOS */
export function isMacOS(): boolean {
return getPlatform() === 'darwin';
}
/** 是否为 Windows */
export function isWindows(): boolean {
return getPlatform() === 'win32';
}
/** 是否为 Linux */
export function isLinux(): boolean {
return getPlatform() === 'linux';
}
/** 获取平台名称(中文) */
export function getPlatformName(): string {
if (isMacOS()) return 'macOS';
if (isWindows()) return 'Windows';
if (isLinux()) return 'Linux';
return '未知平台';
}
/**
* 获取快捷键修饰符展示文本
* macOS 用户习惯 ⌘ 符号Windows/Linux 显示 Ctrl
*/
export function getShortcutDisplay(key: string): string {
if (isMacOS()) {
return `⌘+${key}`;
}
return `Ctrl+${key}`;
}
/**
* 获取适合当前平台的菜单行为提示
*/
export function getContextMenuHint(): string {
if (isMacOS()) {
return '右键点击 或 Ctrl+点击';
}
return '右键点击';
}
/**
* 判断是否为开发环境
*/
export function isDev(): boolean {
return import.meta.env.DEV;
}
/**
* 判断是否为生产环境
*/
export function isProd(): boolean {
return import.meta.env.PROD;
}

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

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
declare global {
interface Window {
ipcRenderer: {
on(channel: string, listener: (event: unknown, ...args: unknown[]) => void): unknown;
off(channel: string, listener: (...args: unknown[]) => void): unknown;
send(channel: string, ...args: unknown[]): void;
invoke(channel: string, ...args: unknown[]): Promise<unknown>;
};
}
}
export {};