Compare commits

153 Commits

Author SHA1 Message Date
浅念 8d99f7e10f fix(deploy): server测试用项目venv解释器——系统Python无django导致首验即停
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 17:15:44 +08:00
浅念 5a2c896756 docs: 新建CLAUDE.md——部署唯一入口与红线约定
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 17:11:42 +08:00
浅念 efa1b78526 feat(deploy): 一键部署脚本——tar真实路径直落挂载点/失败即停/哈希验证/部署日志
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 17:10:48 +08:00
浅念 3e42697038 chore: 入库设计稿与品牌logo资产,gitignore部署日志与临时页
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 17:07:21 +08:00
浅念 4500c12d3e docs: 计划敏感扫描assets改用find(未add前ls-files为空)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:56:32 +08:00
浅念 76bccd55bd docs: 部署计划吸收审查4项修正——tar排除.venv(97MB)/敏感扫描前置/mkdir先于参数解析/脏注入具体化/用户背景模板提醒
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:52:10 +08:00
浅念 e11b7c4b49 docs: Git托管+一键部署实现计划(6任务:卫生/推送/deploy.sh/CLAUDE.md/验证/收尾)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:36:45 +08:00
浅念 ea975214e2 docs: 部署spec吸收审查建议——tar真实路径直落挂载点/单目标语义表/CLAUDE.md实际不存在改为新建/logs-gitignore前置
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:19:00 +08:00
浅念 785260be4f docs: Git托管接入+一键部署方案设计文档
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:11:06 +08:00
浅念 bfd2040496 docs: 阶段B验收台账——D02观察窗通过/归因v2口径/线上链路验证/交付清单
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 17:36:25 +08:00
浅念 c9967b6d70 feat(admin): 时间线业务事件徽章+询盘漏斗卡+付费社交渠道标签(含server时间线安全字段)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 00:12:13 +08:00
浅念 8d7fc99ef6 test(sdk): track宿主伪造归属键被快照覆盖的权威性回归
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 23:57:39 +08:00
浅念 fea19c15cd feat(sdk): 0.2.1——track补归属+business接线
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 23:47:44 +08:00
浅念 7b670f274c feat(sdk): business.ts——咨询点击/CF7状态机/产品页自动监听
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 23:28:01 +08:00
浅念 0ef01b0d4f feat(server): 询盘漏斗API——五级独立distinct会话计数/排除机器人
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 23:05:04 +08:00
浅念 52a8a72e3a feat(server): 归因v2——paid_social渠道与fb+付费medium优先规则
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 22:41:58 +08:00
浅念 be0b53bce4 fix(server): 契约正则锚点\Z闭合——尾换行脏值不再放行
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 22:24:45 +08:00
浅念 3b9d8ecee0 feat(server): 业务事件契约——白名单+6事件与枚举校验
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 22:10:20 +08:00
浅念 4a54087070 docs: T6文件清单补server时间线文件(审查advisory)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 20:14:53 +08:00
浅念 baea776b7b docs: 阶段B计划吸收审查修正——T6前置server时间线白名单扩展/归因分支位置与有意分歧钉死/漏斗窗口与site条件/unaccepted测试/SITE_ID
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 20:13:03 +08:00
浅念 71ae7ee5a2 docs: 阶段B实现计划(8任务:契约/归因/漏斗API/business.ts/track接线/admin/部署/测试询盘)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 20:05:09 +08:00
浅念 d820e4305e docs: spec按CF7 6.1.6源码实测修正——wpcf7statuschanged状态机/原生submit触发form_submit/时序确认
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 20:03:43 +08:00
浅念 a65cee162f docs: spec三处交叉引用同步——submission_id生成点统一为原生submit捕获/§7风险表述对齐
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 18:09:53 +08:00
浅念 fd3ffc8bc2 docs: 阶段B spec独立复审修正——fb+paid优先规则(实测主导模式)/_ctk_sid原生submit捕获/产品段匹配非锚定/校验补全
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 18:06:59 +08:00
浅念 f4af77d261 docs: 阶段B spec第四轮修正——mailsent验证表述统一/校验归位§2/状态行与CF7版本措辞
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:56:49 +08:00
浅念 c10bf456d0 docs: 阶段B spec合入第三轮修正——漏斗逐级独立口径+is_bot过滤/归因重算预期管理/真实测试询盘闭环
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:51:26 +08:00
浅念 50afd23a26 docs: 阶段B spec吸收审查修正——onSegmentChange触发点钉死/空占位策略/recompute已存在/form_id格式/error_code前向桶
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:47:05 +08:00
浅念 be9c9ee1bf docs: 采集阶段B设计文档——零网站改动业务事件体系(含两轮自审17项修正)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:41:01 +08:00
浅念 465de5020e fix(admin): 时间线主体双重内边距修正+模板尾部空行清理
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 16:59:07 +08:00
浅念 02ecc7ae84 feat(admin): 访客详情三栏样式——2fr/1fr/1fr、统计卡2×2、断点1400/1000替换旧规则
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 16:43:05 +08:00
浅念 550e180318 feat(admin): 访客详情模板重组三栏Tabs——时间线/会话/原始数据合并左栏
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 16:40:24 +08:00
浅念 47e8f1bd01 docs: 计划Files行残留锚点笔误修正
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 16:25:53 +08:00
浅念 14d1caf9e8 docs: 计划修正底卡/抽屉行号锚点(329止,332+抽屉严禁触碰)+断点advisory吸收
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 16:23:44 +08:00
浅念 a38da904f1 docs: 访客详情三栏Tab化实现计划(3任务:模板重组/样式断点/四档验证部署)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 16:18:28 +08:00
浅念 42ffcf73c5 docs: spec吸收审查修正——旧媒体查询必须替换/activeTab类型与初始值/1100验证视口
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 16:16:07 +08:00
浅念 5e127ce4ec docs: 访客详情页三栏Tab化设计文档
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 16:13:03 +08:00
浅念 6173d97df0 docs: 台账补终审结论——track通道留阶段B/多标签并行会话行已知行为/记录性事项
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 15:39:24 +08:00
浅念 1e55286a76 docs: 阶段A验收台账——生产部署记录/OT映射/量化指标/遗留清单
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 15:25:52 +08:00
浅念 87261abf16 fix(server): PV统计加event_name过滤——增量停留事件不再灌倍PV(gzip 4817B记录:0.1.0为3838,增量989)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 15:16:05 +08:00
浅念 f24060de87 feat(sdk): 0.2.0接线——归属快照/区间停留/清洗对齐/响应契约全链路
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 14:56:56 +08:00
浅念 2a9c98c993 feat(sdk): 入队前清洗三轨制对齐服务端(共享向量)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 14:37:23 +08:00
浅念 08495bc365 fix(sdk): 超预算组丢最旧闭环+4xx丢弃计数(I1/M1)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 14:27:26 +08:00
浅念 6432435c93 feat(sdk): 传输快照信封+混合拆批+beacon预算+响应计数
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 14:07:45 +08:00
浅念 b0a8afcc19 docs: T10清单补活动监听合并评估备忘(Task7审查遗留)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 13:46:04 +08:00
浅念 25e37f56dc fix(sdk): stayIntervalMs边界守卫——0/NaN钳制下限防事件灌入
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 13:45:36 +08:00
浅念 d6b2289cee feat(sdk): 停留改单调时钟区间制——15s切分/共享结算/幂等
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 13:29:24 +08:00
浅念 d7d5ed9c94 fix(sdk): flush前resume检查点预埋+onClose晚注册即触发+归属预留字段从快照展开
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 13:02:19 +08:00
浅念 9cd2a65735 feat(sdk): PageSession事件级归属冻结+会话恢复+bfcache+SPA切段
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 12:45:59 +08:00
浅念 77ca0ea110 feat(sdk): 版本注入与事件归属骨架(0.2.0/build_id/schema_version)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 12:22:05 +08:00
浅念 0cb8c6fb0c test(server): v1/v2混合停留聚合回归+聚合点核查适配
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 12:12:03 +08:00
浅念 5e024b814b fix(server): v2入口加固——page_id孤代理/NaN区间值拒绝,共享ISO解析
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 11:53:02 +08:00
浅念 8459e6fa13 feat(server): ingest v2事件级归属+session_resume+stay区间并集重算+响应明细契约
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 11:14:06 +08:00
浅念 d8999035c9 feat(server): schema v2契约——session_resume白名单/Event归属与区间字段/会话resume_of/request_id
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 10:38:20 +08:00
浅念 5881046a2d feat(server): URL清洗三轨制+共享测试向量(D08)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 10:15:42 +08:00
浅念 73af568c3f docs: define键改点链形式(esbuild成员表达式替换实测约束)+spec同步slug安全线32口径
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 09:55:29 +08:00
浅念 1f39fe9e1b docs: 阶段A计划吸收审查修正——__BUILD_ID__测试期define与ambient声明、slug安全线32并补33-64向量、保留字/resolveJsonModule/null防御
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 09:50:13 +08:00
浅念 03b8b60dd8 docs: 采集底座修复阶段A实现计划(12任务:server清洗/契约/ingest→SDK骨架/PageSession/区间停留/传输/接线→部署)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 09:39:43 +08:00
浅念 8526ba5a97 docs: 阶段A spec 吸收审查修正——幂等约束已存在/sent_at已有/stay_ms重算式与v1v2混合解释
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 09:31:49 +08:00
浅念 01cf26aabf docs: 采集底座修复阶段A设计文档(归属快照/区间停留/清洗三轨制/契约升级)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 09:25:56 +08:00
浅念 28fad05031 feat(admin): 侧栏隐藏系统名文字只留K标,全局系统名改为客迹
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 18:16:40 +08:00
浅念 8a3003bf4a feat(admin): 侧栏/顶栏logo换成客迹K标(裁自品牌logo,浅深背景通用)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 18:05:13 +08:00
浅念 8aea3d8916 feat(admin): 登录表单恢复Art Design Pro原版样式(紧凑40px/左对齐/无图标),左侧保留品牌区
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 17:16:54 +08:00
浅念 803fc3d611 feat(admin): 登录表单按验收反馈精简——去标签/去底部文案/滑块与输入框等高
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 17:14:04 +08:00
浅念 d7121640f5 fix(admin): 登录样式构建修复——flex-cc换核心工具类,注册/忘记密码页补固定浅色
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:47:30 +08:00
浅念 b9eaa5a32a feat(admin): 登录表单区对齐客迹V1——移除账号下拉/注册/忘记密码入口,固定浅色
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:30:19 +08:00
浅念 8c5c913b1a chore(admin): 入库登录页品牌区素材(白字logo+深蓝背景图)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:22:54 +08:00
浅念 d7e250e1fb feat(admin): 登录左侧品牌区重做为客迹V1深蓝视觉
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:19:34 +08:00
浅念 8841c85c20 feat(admin): 登录页i18n文案对齐客迹V1
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:13:45 +08:00
浅念 433721f8c0 docs: 登录页客迹V1实现计划——吸收审查建议(滑块文字色硬编码、统一56px、移除明文密码)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:10:41 +08:00
浅念 8a88bbacb9 docs: spec 吸收审查建议——rememberPwd改保持登录、色值归属修正、暗色滑块条件删除、补英文文案
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 15:50:31 +08:00
浅念 b8dc17d2a6 docs: 登录页客迹视觉V1对齐设计文档
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 15:46:28 +08:00
浅念 62aee34827 feat(admin): 渠道徽章显示渠道词——付费广告/AI引用等一目了然
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 15:08:47 +08:00
浅念 35f7b55946 fix: 趋势改实时聚合+DailyStat自愈回写,摆脱cron依赖
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 14:17:08 +08:00
浅念 8f20d8d9a2 feat(admin): 数据总览按客迹视觉V1重做(周期卡/趋势双线/渠道条形/最近访问)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 12:18:37 +08:00
浅念 132e2feb75 fix: 来源冲突比较升级为广告家族归一(meta/google/bing/yandex 家族)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 11:23:02 +08:00
浅念 aba9e8eb26 chore: exports 目录不入库
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 22:32:21 +08:00
浅念 22d89a3f42 feat: 移动端设备信息最大化采集(型号/UA-CH/视口/方向/触点/内存/网络/深浅色)
SDK:buildEnvContext 同步扩展 12 键(dpr/viewport/orientation/maxTouchPoints/
deviceMemory/languages/colorScheme/standalone/network downlink·rtt·saveData/
微信版本),逐键 try/catch 静默缺则不挂;新增 collectHighEntropyHints(UA-CH
model/platformVersion/architecture/bitness),init 后 Promise.race 2s 超时
异步并入 __ctk_ctx——批次 context flush 时读取,不阻塞首屏。

后端:Visitor 加 device_model/env_extra(迁移 0005);ingest 首批快照
EXTENDED_ENV_KEYS 白名单(值截 100),非首批不覆盖、唯 device_model 空且
本批有值时补填一次(晚到 hints);列表/详情 API 随行输出。

前端:详情访问环境卡设备行升级显示型号(手机 · Pixel 7),追加像素比/
视口/屏幕方向/触点/内存/网络质量/深浅色/微信版本行(env_extra 逐键判空,
旧数据整块不显示);列表设备列主行拼型号(手机·Pixel 7)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 19:12:20 +08:00
浅念 7f2a746f66 fix: 排序作用于组内事件+时间线限高滚动+UTM别名归一消冲突误报
- attribution.py:新增 _canon 归一(小写/去协议与域名后缀/别名表 fb→facebook、百度→baidu 等/仅留字母数字汉字),
  冲突判定改为两路各自归一后比较——同源升级 combined 并在 detail 记 utm_source 原文,仍不同才 conflict
- detail.vue:orderedDays 组内会话/事件同步翻转(desc=组与组内都最新在前,asc=都最早在前);
  .tl-body 限高 calc(100vh - 380px) 卡内滚动,日期组头随内容滚动
- test_attribution.py:+4 用例(fb↔facebook、chatgpt.com↔ChatGPT、google↔facebook 仍冲突、百度↔baidu)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 18:39:02 +08:00
浅念 50faa73cf9 feat(admin): 访客详情页按客迹视觉V1重设计(头部卡/统计卡/时间线/来源证据三口径/访问环境)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 18:19:31 +08:00
浅念 008d9277d8 feat: 来源组合显示——徽章/识别卡拼装落地页·国家·语言(搜索意图线索)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 17:43:37 +08:00
浅念 f9c8c354ae feat(admin): 来源归因展示——渠道徽章/筛选/来源识别卡/渠道构成图
- 类型:VisitorRow 补 channel/platform/variant/search_term/resolution,新增
  AttributionInfo/AttributionBlock,详情响应带 attribution 块;dashboard
  响应补 channels(ChannelItem)
- 工具:CHANNEL_LABELS 八渠道元数据 + channelBadge 徽章串(搜索词/标签/
  冲突后缀,未归因回 —)
- 访客列表:首触来源列升级渠道徽章(first_source 进 tooltip 兜底),
  筛选栏加渠道下拉(channel= 枚举)
- 访客详情:首触来源升级来源识别卡(渠道徽章/平台/国家站/搜索词显著
  展示,label_only/conflict 诚实标注,冲突并列两路证据文本渲染),
  无归因旧数据降级原 first_source 口径
- 总览:来源饼图改渠道构成(标签/颜色按渠道绑定 CHANNEL_LABELS),
  下方保留平台明细小表(原 sources 域名口径)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 17:19:31 +08:00
浅念 64d5daf921 feat(sdk): page_view 增加 nav_type/prerendering 来源信号(预渲染=AI搜索指纹)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 17:09:22 +08:00
浅念 59de51c062 feat: 来源归因规则引擎(渠道/AI/搜索词/诚实状态/历史重算)
- constants 规则库 v1:AI/引擎/社交域名表、搜索词参数白名单、付费点击参数、
  应用内 UA 标记、CHANNELS 枚举;page_view 白名单开 nav_type/prerendering
- services/attribution.classify 纯函数:付费>AI>引擎>社交>referral 分层,
  UTM 证据叠加(combined/conflict/label_only),应用内 UA 叠加/兜底,
  引擎 TLD 国家 variant,搜索词白名单提取(Google 恒空不虚构)
- SourceClassification 派生表(迁移 0004):first_touch/session_entry 双 scope,
  rule_version 可追溯
- ingest 增量 best-effort:首访客/新会话时分类落库(savepoint+静默,
  失败不影响采集);validation 对 page_view referrer 完整 URL 参数清洗+截500
- recompute_attribution 命令:全量/按站幂等重算
- API:访客列表归因五字段+channel 筛选、详情 attribution 块(含最近5会话入口)、
  dashboard channels 聚合(sources 原口径保留);TS VisitorListParams 加 channel
- 测试 97 → 124(引擎20 + 集成/接口7)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 17:03:15 +08:00
浅念 8bde9b5b4b docs: 记录正式采集开始日期与 IP 截断期限(2026-09-16/2027-03-15)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 16:43:16 +08:00
浅念 21ca43248a feat: 原始上报数据存储(raw_batch 表/脱敏/访客关联/详情页 Tab/30天清理)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 15:42:06 +08:00
浅念 1cadee16bd feat(admin): 后台瘦身——数据总览为默认页/精简菜单/顶栏去演示组件
- 默认页:HOME_PAGE_PATH 指向 /tracker/dashboard,登录/根路径/logo 点击统一落数据总览
- 菜单:tracker 路由扁平为顶级三项(数据总览/访客管理/站点管理),访客详情保持 hidden;
  移除模板演示路由(工作台/分析页、结果页、异常页、系统管理 mock 页),直达返回 404;
  system 仅保留隐藏的个人中心路由供头像菜单使用
- 顶栏:配置化停用通知铃铛/快捷入口/全局搜索(含 Ctrl+K)/聊天窗口,保留主题/语言/全屏/设置与头像菜单
- worktab:命中 catch-all 404 的遗留标签视为无效自动清理(如旧"工作台"标签)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 15:15:52 +08:00
浅念 58659b0ec9 feat: 访客列表站点归属显示与筛选
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 14:33:39 +08:00
浅念 15ad3fb200 fix: nginx XFF 改透传——双层代理下追加自身会掩盖真实客户端IP(生产踩坑修复)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 14:18:25 +08:00
浅念 403b8516b0 chore: 采集平台对外端口改为 8018(用户指定)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 11:09:03 +08:00
浅念 5f5e64205e docs: 部署指南明确单站点架构——compose 映射 8080,1Panel 建一个反代网站(前后端 SDK 同域共用)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 10:18:59 +08:00
浅念 fe3fcfab07 chore: 入库需求基线文档与验收截图,忽略会话临时产物(终审卫生项)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 22:13:00 +08:00
浅念 1e39aa122f docs: M1 验收记录(本地全栈 A 部分 8/8,用户项清单)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 22:03:24 +08:00
浅念 7ed55c8a8d chore: Docker/Nginx 部署产物与 1Panel 指南
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 21:32:20 +08:00
浅念 1cb199e99e fix(admin): 详情页时间线兜底/事件组件抽取/失败态区分/换访客清理
- timelineSessionSource 查不到会话摘要时改显示 —:无事实依据不做
  "直接访问"断言(sessionSourceText 内的直接访问是后端同口径语义,保留)
- 抽取 EventTimeline 子组件(components/EventTimeline.vue):
  轨迹 Tab 与会话抽屉的 page_view/page_stay/其他三分支事件行模板
  去重为单一实现,两处各一行引用;stay-line/title-line 样式随迁
- 时间线失败态三分支:有数据渲染分组 / 请求结束且无数据对象提示
  "时间线加载失败" / 成功但窗口内无事件提示"暂无访问轨迹"
- ensureLoad 换访客时同步清 detail,与 timeline/抽屉清理对齐,
  防旧访客 ID 在 loading 遮罩下闪现

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 21:21:20 +08:00
浅念 ea561c974a feat(admin): 访客详情跨天时间线
- 覆写 detail.vue 占位:档案卡(ID/在线/地理/IP/语言时区/设备/首触来源 utm 标签/
  落地页截断 tooltip)+ 统计 4 项 + 轨迹/会话 Tab
- 轨迹 Tab:按日倒序分组(日期头含会话数)、会话块(时间·来源·active_ms 求和停留)、
  el-timeline 事件行(page_view/page_stay 独立条目/sdk_loaded 图标区分)、
  7/30/90 天切换(reqSeq 守卫)、truncated 提示条、空态
- 会话 Tab:全量会话表,行点击开抽屉看单会话事件序列(getSession)
- keepAlive:onMounted/onActivated/query-watch 三入口统一 ensureLoad,
  loadedVid 去重(query 清空时重置令牌),切访客必刷新
- 顺车 Minor:formatStay 注释与代码对齐;useTable 背对背 if 改 if/else

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 21:05:52 +08:00
浅念 ba31670095 docs: Task20 注记——来源筛选无 API 支持,M1 以显示爬虫替代
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 20:16:27 +08:00
浅念 99d2a300ba feat(admin): 访客列表页
- 访客管理页:useTable 分页表(后端固定 20/页,paginationKey 映射 page,
  size 不发送),列含 截断 ID+复制 / 国旗 emoji+国家·省市 / 设备主行+os·browser
  小字 / 首访·最近访问(共享 formatDateTime,上海时间直显)/ 访问次数 /
  活跃天数 / 总停留 formatStay / 首触·最近来源 / 在线绿点;行点击进详情路由
- 筛选栏:关键词(visitor_id/IP 前缀)回车或查询触发、设备下拉、国家精确、
  在线开关(online='1' 哨兵)、日期范围 date_from/date_to、显示爬虫开关
  (默认排除);筛选变化重置 page=1,分页切换保留筛选
- 共享工具提取 admin/src/utils/tracker:formatDateTime/formatStay/countryFlag,
  site 页改引用;formatStay 智能省略(0 单位不显示,全 0 显示 0秒)
- 红线:无 v-html,访客数据不绑 href/src;加载/空态齐全(错误态走请求层统一 toast)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 19:58:23 +08:00
浅念 3c54ad69c1 fix(admin): useTable 请求竞态守卫(旧请求不清 loading/不写错误态)
- fetchData 加请求序号令牌 fetchSeq:请求被新请求取代后,旧响应不再
  改动任何状态——修复两类竞态:旧请求取消分支把 loading 提前清成 idle、
  旧请求失败分支把新请求在途数据清空并置 error
- 与 dashboard reqSeq 修法同口径(id===seq 才写数据/清 loading),
  供访客列表等分页筛选页复用

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 19:58:14 +08:00
浅念 5b3297337e fix: 总览筛选竞态守卫 + 时间戳统一上海时间序列化
- dashboard loadOverview 加请求令牌:快速切换站点时丢弃过期响应,
  防旧数据覆盖与旧 finally 提前清 loading
- 后端序列化统一 timezone.localtime:sites.created_at 与 visitors 的
  first/last_seen_at、会话 started/ended_at、时间线事件 time——
  USE_TZ 下 isoformat() 是 UTC,与上海自然日口径同屏矛盾(差 8 小时)
- 站点弹窗域名加轻量主机名正则校验与实时提示(拦含空格等垃圾输入)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 18:57:41 +08:00
浅念 3eb703b4ac docs: Task20 执行约定(keepAlive 响应/语义刷新/共享工具/时区/竞态守卫)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 18:48:32 +08:00
浅念 78ed08d217 feat(admin): 站点管理与总览页
- 站点管理:useTable+ArtTable 列表(名称/Site ID 复制/域名白名单标签/状态/创建时间),
  新建/编辑复用弹窗(域名按行归一预览,与后端 _norm_domains 同口径),
  停用二次确认(软删语义,文案用停用)/启用切换
- 数据总览:今日五指标卡(PV/UV/会话/新访客/在线)、近30天趋势折线、
  来源构成环图、热门页面表格;站点筛选下拉(哨兵值全部站点)联动刷新
- 图表用模板 useChartComponent 管理实例(自动销毁/空态/主题色)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 18:32:25 +08:00
浅念 0a80b43804 fix(admin): getVisitors 参数类型收紧(哨兵字符串防假筛选)
online/is_new/show_bot 后端按字符串哨兵 =="1" 解析(visitors.py _page_visitors),
boolean true 会被 axios 序列化成 "true" 而被静默忽略——看起来生效实则未生效。
新增 VisitorListParams 接口把三个开关锁死为字面量 "1",其余筛选字段一并收型。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 17:59:23 +08:00
浅念 a2ec4510a7 feat(admin): tracker api 模块与路由
- api/tracker/{visitors,dashboard,sites}.ts:类型逐字段对齐 server/tracker/api 序列化输出
  (stay 一律 stay_ms,不引用已移除的 duration_ms;last_ip/时间线孤立会话组的
  started_at 按后端可为 null;TrendPoint 含 new_visitors)
- 路由模块 tracker:数据总览/访客管理/站点管理三项菜单,访客详情 isHide 不进菜单
  (query: visitor_id,activePath 回指访客管理),zh/en 语言包补 menus.tracker
- 4 个占位视图(Task 19-21 填充真实内容),vue-tsc 构建通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 17:50:36 +08:00
浅念 f11abbff43 fix(admin): 微收口(注释/角色防御/dev 约定文档/husky 移除)
- routesAlias.ts 修复 clean:dev 打坏的块注释
- system-manage.ts menus/simple 标注死代码(backend 模式前置条件)
- /me 映射非 staff roles 兜底 R_USER,防空数组跳过菜单角色过滤
- 新增 README-dev.md:config-object 签名/URL 无 /api 前缀/Bearer 归映射层/v-html 红线
- package.json 删 husky prepare(防 pnpm install 改写 core.hooksPath)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 17:39:50 +08:00
浅念 3a1f0a0b19 docs: v-html 红线细化(业务代码禁用+模板组件不喂访客数据)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 17:38:09 +08:00
浅念 ab4f40ba5d feat(admin): 模板精简+请求层适配 Django JWT
- 克隆 art-design-pro v3.0.2(GitHub depth1),rm .git,pnpm clean:dev 精简演示页(92 文件)
- .env.development/.env.production 均设 VITE_API_URL=/api,代理目标 127.0.0.1:8000(移除 Apifox Mock)
- http 封装信封适配:code===200 解包 data;msg/message 字段兼容(拦截器/error/类型)
- /api/admin/auth/token 登录适配(access->Bearer token, refresh->refreshToken);/api/admin/auth/me 映射 UserInfo(userName/roles/is_admin)
- 登录页演示账号替换为真实 admin;logOut 按路由名判登录页修复 redirect 嵌套
- 根 .gitignore 补 !admin/.env(模板公共 env 非密钥,需入库)
- 验证:Playwright 全链路(错误密码 401 留登页/正确登录进工作台/登出/受保护路由重定向/redirect 回跳)+ pnpm build(vue-tsc 通过,产物无 mock 域名)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 17:17:38 +08:00
浅念 e56f252ffd fix: ingest 落库 SDK timezone 到 Visitor.timezone_name(Task16 联调发现)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:59:24 +08:00
浅念 0fa51e77ef docs: Task16 SDK↔后端联调记录
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:51:50 +08:00
浅念 21c3cb2423 fix(sdk): §4.5 红线补全(act/重放包裹)+ beacon-false 丢批计数 + 空闲负向测试 + es2020
- events.ts:act 回调与 visibilitychange 监听体包 try/catch(字面红线)
- transport.ts:sendBeacon 返回 false(64KB 超限等)按批事件数计 dropped
  ——与 stringify 丢包计数对称的缺口
- index.ts:顶层队列重放双层包裹,单个坏条目只跳过自身不吞后续 init/track
- 测试:30s 空闲负向(active_ms=29000 后停止)、beacon-false 丢批;44→46
- build.mjs target es2018→es2020(代码依赖 globalThis,目标诚实化)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:40:35 +08:00
浅念 dc13ae6e0e feat(sdk): 页面浏览/有效停留/事件入口,打包≤15KB
- events.ts:page_view/page_stay(有效停留=可见+30s 内有活动),
  close 双触发(hidden+pagehide)靠 activeMs 清零不重发
- index.ts:IIFE 入口,队列重放前先替换 g.ctk=dispatch;
  首触归因——__ctk_ctx 除设备环境外带 referrer_domain/utm/path
- transport.ts:stringify 抛错(track 宿主可控对象)按已换出
  批次事件数计 dropped(Task 14 审查加固)
- demo 双页互跳;gzip 2209B ≤ 15360B;测试 35→44

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:28:14 +08:00
浅念 188e6f39ea fix(sdk): transport 测试注释修正(sendBeacon 真实网络警示/退避区间)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:13:37 +08:00
浅念 6be163a329 feat(sdk): 批量传输(批量/退避/信标/丢包计数)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:07:18 +08:00
浅念 18537c5dba fix(sdk): 设备检测顺序修订(iPad/Firefox 平板先判)+ FxiOS 前移
- device_type:iPad/Tablet 先于 Mobile/Android/iPhone 判(真实 iPad UA 恒含 Mobile 串、Firefox Android 平板显式 '; Tablet;',旧顺序误判手机)
- browser:Firefox/FxiOS 前移至 Safari 之前(iOS Firefox UA 尾部恒含 Safari/,旧顺序为死代码)
- 测试 23 过(翻转 iPad 锁定测试 + 新增 Android 平板、iOS Firefox 两条)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 15:58:42 +08:00
浅念 8e4d5a3be5 docs: Task13 规格修订——UA 检测顺序(iPad/Tablet 先判、FxiOS 前移)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 15:57:57 +08:00
浅念 c58e49bf5b feat(sdk): 身份与环境上下文模块
- identity.ts: 访客/会话 ID(30min 空闲轮换),localStorage 异常统一内存降级不冒泡(spec §4.5 红线)
- context.ts: buildEnvContext/parseUtm/sanitizeUrl/pageContext;参数黑名单与 server URL_PARAM_DROP_PATTERNS 同源(整键精确匹配、大小写不敏感、保留空值)
- 测试 21 过(含隐私模式降级、Edg 先于 Chrome、微信 UA 判定、iPad-Mobile 规格顺序锁定)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 15:48:17 +08:00
浅念 fa90835ffa fix: 访客API 微收口(duration_ms 移除/truncated 标记/锁定测试/source 收敛)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 15:38:58 +08:00
浅念 9a2cd76e99 feat: 访客列表/详情/时间线/会话接口
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 15:20:06 +08:00
浅念 0b93814261 chore: dashboard 确定性排序与注释修正
- sources/top_pages Top8 加平局次序键(referrer_domain/path),
  平局时跨请求顺序稳定
- test_dashboard 修正过时注释(b 站数据同为 refresh_daily 真实落行)

pytest 79 passed 无回归。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 15:06:21 +08:00
浅念 a0f7bdb87e feat: 总览聚合接口
GET /api/admin/dashboard/overview?site=<site_id>(site 可选,缺省聚合全部站点):
- today:daily_counts 实时四指标(与 refresh_daily 共享口径)+ active_now
  (Visitor.last_seen_at 5 分钟窗口,走 site,-last_seen_at 索引)
- trend:DailyStat 近 30 天 values(date).annotate(Sum) 跨站点求和,
  缺日补零,今天一位用实时值覆盖(聚合作业未跑也不缺当日)
- sources/top_pages:当天上海自然日窗口 Top8,空来源归"直接访问"
- ?site= 不存在 → 404 信封 site_not_found;未认证 401(默认权限)

测试 4 个(认证/数值与形态/DailyStat 历史/站点过滤与 404),全套 79 passed。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:53:01 +08:00
浅念 cf925e8763 refactor: stats 提取共享 daily_counts
refresh_daily 四指标口径提取为模块级 daily_counts(site, date),
site=None 时聚合全部站点,为 dashboard 今日实时位复用做准备
(Task8 审查结论:防两处口径漂移)。语义不变,既有 stats 测试全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:50:09 +08:00
浅念 eb27bc830b fix: sites 标量 domains 防御/_to_bool/403 锁定/注释
- _norm_domains 入口类型守卫:domains 传数字曾 500、字符串曾逐字符
  迭代成单字符域名静默落库,统一走 invalid_domains 400
- bool() 强转换 _to_bool:"false" 字符串曾是 True,对 allow_no_origin
  这类放宽开关方向不利,改按字面解析(其余未知值仍 False)
- 补 member DELETE 不存在 pk → 403(先于 404)锁定测试
- SiteDetailView 注释删去 GET 字样(无 detail GET,前端走列表+Dialog)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:43:26 +08:00
浅念 16207ae309 feat: 站点管理接口
GET/POST /api/admin/sites、PATCH /api/admin/sites/<pk> 登录成员可用;
DELETE 仅 admin,走软删(is_active=False),物理清除留待未来管理命令
分批删(防大站级联删除锁表),CASCADE 仅完整性兜底。域名归一去协议/
端口/路径、小写、去空去重;错误响应统一信封(EnvelopeMixin)。
软删后 collect 上报 403 not_allowed(Task7 联动,测试锁定)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:29:18 +08:00
浅念 f22786d746 refactor: 提取共享信封模块 EnvelopeMixin/ok
auth.py 的 _EnvelopeMixin/_ok 提取到 tracker/api/envelope.py(去下划线前缀),
auth.py 改为 import 使用;collect.py 的 finalize_response 含 ACAO 回显逻辑
保留在自身视图,仅注释指向未来收敛。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:25:14 +08:00
浅念 7ff1536fbf fix: auth 畸形body防御/字段校验信封/用户名校验/429 测试
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:18:37 +08:00
浅念 3e58fe003f docs: Task10 增加共享信封模块提取指令(Task9 审查结论)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:16:20 +08:00
浅念 754ae1d39d feat: JWT 登录与用户接口
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:01:59 +08:00
浅念 77a7f568b2 fix: new_visitors 排爬虫+日界/爬虫口径回归测试
- refresh_daily new_visitors 加 is_bot=False(爬虫不是受众,四指标口径统一)
- test_refresh_daily_shanghai_boundary:固定 UTC 时间戳钉死上海自然日界
- test_refresh_daily_excludes_bots:事件/会话/访客 is_bot 均不计入
- refresh_stats 删未使用的 stat = 赋值

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 13:55:13 +08:00
浅念 79a26c77f3 docs: Task11 增加共享 daily_counts 实现约束(Task8 审查结论)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 13:53:06 +08:00
浅念 cc1ea6805b feat: 日聚合服务与命令 + 索引迁移打包(Task3 审查结论)
- refresh_daily:按上海自然日聚合 pv/uv/sessions/new_visitors,update_or_create 幂等
- refresh_stats 管理命令:回刷近 N 天(生产由 1Panel 每日 00:10 调度,见 Task 22)
- 索引打包(0002):Event/VisitSession 增加 (site, 时间) 日聚合索引;
  删 5 个冗余单列 FK 索引(Event.site/session、VisitSession.site/visitor、DailyStat.site),
  保留 Event.visitor/Visitor.site;sqlite PRAGMA 实证级联索引完整、EXPLAIN 走新索引
- test_models:IntegrityError 断言收紧 + Site 级联删除测试

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 13:38:01 +08:00
浅念 f69822859e docs: Task7 收口注记(urlsplit/信封/CL 防御/get_rate)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 13:30:54 +08:00
浅念 678d215b31 fix: 采集接口 origin urlsplit/finalize_response 信封/CL 防御
审查三项必修:
1. Origin host 提取改 urlsplit(封堵 https://evil.com/?x=https://www.a.com
   类查询串绕过);"null" Origin 视为无 host 走 allow_no_origin;
   Referer 回显死逻辑一并清理,ACAO 只回显真正的 Origin 头。
2. finalize_response 统一钩子:429/ParseError 等 DRF 异常响应补 ACAO +
   {code,data,message} 信封(浏览器 SDK 需读错误体,误判网络错误重试会
   打爆限流器);删 post/_deny/options 三处临时加头代码。
3. Content-Length isdecimal 防御("abc" → 400 非 500;isdigit 放过 "²"
   而 int("²") 仍崩,isdecimal 与 int() 可解析集精确对齐)。

锁定测试 ×4:429 信封+ACAO / 坏 JSON 信封+ACAO / 查询串绕过 403 / 畸形 CL 400。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 13:24:31 +08:00
浅念 cf04db794b feat: 采集接口(域名白名单/限流/textplain解析/幂等)
POST /api/collect/batch:text/plain JSON 解析(ParseError 400 防 500)、
域名白名单/站点停用/未知站点统一 403 not_allowed 防枚举、
Anon 限流(collect scope,get_rate 读实时 api_settings 修 import 固化)、
occurred_at ISO 归一(naive 补 UTC)、XFF 末段取 IP + ::ffff: 归一、
成功/拒绝分支均回显 ACAO 供 SDK 读响应。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 13:07:56 +08:00
浅念 acb180e363 docs: Task6 收口注记(会话族切分/同批去重等审查修复)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 13:01:31 +08:00
浅念 173d13dbf3 fix: ingest 同批去重/会话族切分修复/xdb 无关测试/边界加固
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:59:13 +08:00
浅念 6147f2b9ff feat: 批量入库服务(幂等/首触/会话兜底/聚合更新)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:44:33 +08:00
浅念 e880dbe458 fix: geo 收口(模块单例/IPv6测试/降级日志)
- 导出模块级 resolver 单例:Task 6 起 import resolver 使用,
  防多实例各持 ~11MB xdb 副本、lru_cache 按实例分片
- 补公网 IPv6→None 测试,锁定 M1 v4-only 决策
- __init__ 降级路径(文件过短/结构无法识别/except)均记
  warning 日志,运维可区分「没部署 xdb」与「库 API 崩了」
- 删掉测试未使用的 monkeypatch 参数

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:39:08 +08:00
浅念 c5626be589 docs: Task5 收口注记与 Task6/7 指令更新(geo 单例/0占位归一/::ffff: 归一/IPv6 接受)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:38:04 +08:00
浅念 138b472485 feat: ip2region 离线地理解析(优雅降级)
GeoResolver 进程级单例,c_buffer 模式一次读入内存(线程安全)。
适配 py-ip2region 3.0.4 实际 API:new_with_buffer(version, buff) 需
Version 对象,由 xdb 头 256 字节探测(2.0 老库自动归 IPv4)。
数据段布局按结构版本分派:3.0 新库 国家|省|城市|ISP|国家代码,
2.0 老库 国家|区域|省|市|ISP——错位会把 ISP 串进 city。
包/xdb 缺失或损坏时 resolve 一律返回 None,测试全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:28:01 +08:00
浅念 c256d1becb docs: Task4 收口注记(最终实现 7b26c7e 为准)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:22:22 +08:00
浅念 7b26c7e14a fix: 孤代理三入口堵漏+精确断言+截断清洗换序
- _utf8_ok 助手:event_id/path/title 含孤代理("\ud800")在入口即拒
  (原会流到下游 encode 必 500);props 字节守门 try/except 归入 properties_invalid
- 补零覆盖原因码测试:path=123 → invalid_field_type;utm 三层嵌套 → properties_depth
- 子串断言("sensitive" in r 等)全改精确等值断言,码即 API
- path 改为先 sanitize_url 再截 500:中文查询参数 urlencode 膨胀最多 3 倍,
  先截后洗会破坏出门 ≤MAX_PATH_LEN 不变量(实测汉*200 查询洗后截回 500)
- docstring 写明就地写回契约(Task 6 ingest 依赖)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:20:27 +08:00
浅念 c5f900f3bd fix: 校验守门按字节+递归长度+非dict拒绝
- 体积守门改 UTF-8 字节数(原按字符数,"汉"*1500 约 4.5KB 可绕过 4096 上限)
- 字符串长度检查并入 _walk_props 递归,嵌套字符串同样受 MAX_STR_LEN 约束
  (顺序:体积先行,故同一值既超长又超体积时报 properties_too_large)
- properties 传了但非 dict([]/""/0/None)不再被 or {} 静默放行,缺省键仍放行

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:10:56 +08:00
浅念 6086031367 docs: Task4 最终实现同步进计划(敏感键先于白名单/递归扫描/naive 防御)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:05:31 +08:00
浅念 f2a5f7611a fix: 校验加固(嵌套敏感键/naive时间防御)
- _depth_ok 与敏感键扫描合并为单次递归 _walk_props,dict 所有层级键均过
  _SENSITIVE(此前嵌套键不扫描,utm 白名单内可夹带敏感键)
- occurred_at 为 naive datetime 时返回 invalid_occurred_at(原为 TypeError 500)
- 删除未使用的 timedelta 导入
- 深度超限即停止下探,10 万层嵌套不爆栈

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:04:40 +08:00
浅念 cb57e67179 feat: 事件校验服务(白名单/敏感键/时间窗/URL清洗)
TDD:7 测试先红(ModuleNotFoundError)后绿。与计划的一处偏差:
敏感键扫描移至属性白名单之前——白名单内无任何键命中敏感模式,
原顺序下 sensitive_field 不可达,test_sensitive_property_rejected
(user_password 非白名单键)将得到 property_not_allowed 而失败。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:01:45 +08:00
浅念 3242d12f12 docs: 沉淀 Task3 质量审查结论(Task8 索引打包/active_now 走 last_seen_at/站点软删/分区预案)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:56:44 +08:00
浅念 462852c3f3 fix: VisitSession 时间戳恢复 NOT NULL,测试补显式时间(修正计划的测试笔误)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:46:51 +08:00
浅念 79f2a89e0a docs: 修正 Task3 测试笔误(VisitSession 建档补显式时间戳,保持 NOT NULL)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:46:14 +08:00
浅念 d1472c1845 feat: 核心数据模型(Site/Visitor/VisitSession/Event/DailyStat)
VisitSession.started_at/ended_at 在计划模型基础上加 null=True(计划测试
未传这两字段,原 NOT NULL 会导致测试失败;ended_at 为空也表示会话进行中)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:45:03 +08:00
浅念 cda80b1605 fix: Task2 质量修复(TS2774/降级分支测试/跨平台checksize/显式lib)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:39:34 +08:00
浅念 15853f20b3 docs: 计划追溯修正——TS2774 安全 uuid/降级分支测试/跨平台 checksize
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:39:12 +08:00
浅念 8e2c91663e chore: SDK 工程骨架(esbuild+vitest)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:30:42 +08:00
浅念 2a7f08fd14 fix: Task1 质量审查修复(RuntimeError fail-fast/ALLOWED_HOSTS strip/lock 清理/测试缓存隔离)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:27:25 +08:00
浅念 6dbe26af0b chore: Django 工程骨架(config+tracker app+常量)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:16:01 +08:00
431 changed files with 66515 additions and 28 deletions
+22
View File
@@ -3,3 +3,25 @@ __pycache__/
*.pyc
node_modules/
.env
# admin 模板的 Vite 公共环境变量文件(非密钥,模板上游默认值)
!admin/.env
server/.venv/
server/db.sqlite3
server/.cache/
server/tracker/data/*.xdb
sdk/node_modules/
sdk/dist/
deploy/data/
# 会话临时产物与本地杂物
.playwright-mcp/*
!.playwright-mcp/验收-*.png
_artifact_build_complete/
gpt/
/index.html
exports/
# 部署脚本日志(首部署前必须忽略——防日志污染工作区卡住下次门禁)
deploy/logs/
# 根目录临时测试页
/模拟访客测试页.html
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+14
View File
@@ -0,0 +1,14 @@
# CLAUDE.md — 客迹(KEJI)项目约定
## 部署(唯一入口)
- 一键部署:`bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]`
- 用户说"部署到线上" = 执行上述命令(按改动范围选目标)并汇报验证结果与版本标识
- `all` 顺序固定 server → admin → sdk(SDK 新事件依赖 server 白名单先上线)
- 部署日志:deploy/logs/(gitignore);线上真挂载点 /opt/cms-cj/{admin/dist, sdk/dist}(tar 真实路径直落,禁止 cp/暂存方案)
- 严格不触碰服务器其他项目(1Panel/OpenResty/其他容器)
## 红线(沿袭 spec §4.5 等)
- SDK 任何回调异常不得冒泡宿主页;业务代码禁 v-html(admin README-dev.md)
- 一切改动走 TDD;用中文交流
+22
View File
@@ -0,0 +1,22 @@
# 【通用】环境变量
# 版本号
VITE_VERSION = 3.0.2
# 端口号
VITE_PORT = 3006
# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/)
VITE_BASE_URL = /
# 权限模式【 frontend 前端模式 / backend 后端模式 】
VITE_ACCESS_MODE = frontend
# 跨域请求时是否携带 Cookie(开启前需确保后端支持)
VITE_WITH_CREDENTIALS = false
# 是否打开路由信息
VITE_OPEN_ROUTE_INFO = false
# 锁屏加密密钥
VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro
+13
View File
@@ -0,0 +1,13 @@
# 【开发】环境变量
# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/)
VITE_BASE_URL = /
# API 请求基础路径(Django 后端统一前缀 /api,走 Vite 代理)
VITE_API_URL = /api
# 代理目标地址(开发环境通过 Vite 代理转发请求到此地址,解决跨域问题)
VITE_API_PROXY_URL = http://127.0.0.1:8000
# Delete console
VITE_DROP_CONSOLE = false
+10
View File
@@ -0,0 +1,10 @@
# 【生产】环境变量
# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/)
VITE_BASE_URL = /
# API 地址前缀(与后端同源部署,nginx 将 /api 转发给 Django)
VITE_API_URL = /api
# Delete console
VITE_DROP_CONSOLE = true
+2
View File
@@ -0,0 +1,2 @@
*.html linguist-detectable=false
*.vue linguist-detectable=true
+11
View File
@@ -0,0 +1,11 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.cursorrules
# Auto-generated files
src/types/import/auto-imports.d.ts
src/types/import/components.d.ts
.auto-import.json
+1
View File
@@ -0,0 +1 @@
pnpm dlx commitlint --edit $1
+1
View File
@@ -0,0 +1 @@
pnpm run lint:lint-staged
+3
View File
@@ -0,0 +1,3 @@
/node_modules/*
/dist/*
/src/main.ts
+20
View File
@@ -0,0 +1,20 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"vueIndentScriptAndStyle": true,
"singleQuote": true,
"quoteProps": "as-needed",
"bracketSpacing": true,
"trailingComma": "none",
"bracketSameLine": false,
"jsxSingleQuote": false,
"arrowParens": "always",
"insertPragma": false,
"requirePragma": false,
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "auto",
"rangeStart": 0
}
+9
View File
@@ -0,0 +1,9 @@
dist
node_modules
public
.husky
.vscode
src/components/Layout/MenuLeft/index.vue
src/assets
stats.html
+82
View File
@@ -0,0 +1,82 @@
module.exports = {
// 继承推荐规范配置
extends: [
'stylelint-config-standard',
'stylelint-config-recommended-scss',
'stylelint-config-recommended-vue/scss',
'stylelint-config-html/vue',
'stylelint-config-recess-order'
],
// 指定不同文件对应的解析器
overrides: [
{
files: ['**/*.{vue,html}'],
customSyntax: 'postcss-html'
},
{
files: ['**/*.{css,scss}'],
customSyntax: 'postcss-scss'
}
],
// 自定义规则
rules: {
'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url")
'selector-class-pattern': null, // 选择器类名命名规则
'custom-property-pattern': null, // 自定义属性命名规则
'keyframes-name-pattern': null, // 动画帧节点样式命名规则
'no-descending-specificity': null, // 允许无降序特异性
'no-empty-source': null, // 允许空样式
'property-no-vendor-prefix': null, // 允许属性前缀
// 允许 global 、export 、deep伪类
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global', 'export', 'deep']
}
],
// 允许未知属性
'property-no-unknown': [
true,
{
ignoreProperties: []
}
],
// 允许未知规则
'at-rule-no-unknown': [
true,
{
ignoreAtRules: [
'apply',
'use',
'mixin',
'include',
'extend',
'each',
'if',
'else',
'for',
'while',
'reference'
]
}
],
'scss/at-rule-no-unknown': [
true,
{
ignoreAtRules: [
'apply',
'use',
'mixin',
'include',
'extend',
'each',
'if',
'else',
'for',
'while',
'reference'
]
}
]
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"recommendations": [
"vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"stylelint.vscode-stylelint",
"lokalise.i18n-ally",
"bradlc.vscode-tailwindcss"
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"volar.inlayHints.eventArgumentInInlineHandlers": true,
"css.lint.unknownAtRules": "ignore",
"i18n-ally.localesPaths": ["src/locales/langs", "src/locales"],
"i18n-ally.enabledParsers": ["json"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "zh",
"i18n-ally.enabledFrameworks": ["vue", "react"],
"i18n-ally.keystyle": "nested",
"i18n-ally.sortKeys": true,
"i18n-ally.namespace": true
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 SuperManTT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
# admin 开发约定(Task 18-21 执行者必读)
基于 art-design-pro 模板的请求层适配约定,写业务前先读完本文件。
1. **request 是 config-object 签名**:`request.get<T>({ url, params })`,不是 axios 原生 `(url, config)`。
2. **URL 不带 `/api` 前缀**:`VITE_API_URL=/api` 已作为 baseURL,写 `/admin/auth/me` 即请求 `/api/admin/auth/me`;带 `/api` 会变成 `/api/api/...`。
3. **Bearer 不用业务层拼**:token 映射层已存 `Bearer <access>`,拦截器原样设置 Authorization;业务代码只管调 `request.*`。
4. **v-html 红线**:业务代码(`src/views/tracker/**`、`src/api/**`)禁用 v-html;也不得向模板内置 v-html 组件(ArtTextScroll / ThemeSvg / v-highlight)传访客来源数据(UTM、referrer、UA 等用户可控字段)。
+97
View File
@@ -0,0 +1,97 @@
/**
* commitlint 配置文件
* 文档
* https://commitlint.js.org/#/reference-rules
* https://cz-git.qbb.sh/zh/guide/
*/
module.exports = {
// 继承的规则
extends: ['@commitlint/config-conventional'],
// 自定义规则
rules: {
// 提交类型枚举,git提交type必须是以下类型
'type-enum': [
2,
'always',
[
'feat', // 新增功能
'fix', // 修复缺陷
'docs', // 文档变更
'style', // 代码格式(不影响功能,例如空格、分号等格式修正)
'refactor', // 代码重构(不包括 bug 修复、功能新增)
'perf', // 性能优化
'test', // 添加疏漏测试或已有测试改动
'build', // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等)
'ci', // 修改 CI 配置、脚本
'revert', // 回滚 commit
'chore', // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)
'wip' // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)
]
],
'subject-case': [0] // subject大小写不做校验
},
prompt: {
messages: {
type: '选择你要提交的类型 :',
scope: '选择一个提交范围(可选):',
customScope: '请输入自定义的提交范围 :',
subject: '填写简短精炼的变更描述 :\n',
body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
footerPrefixesSelect: '选择关联issue前缀(可选):',
customFooterPrefix: '输入自定义issue前缀 :',
footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
generatingByAI: '正在通过 AI 生成你的提交简短描述...',
generatedSelectByAI: '选择一个 AI 生成的简短描述:',
confirmCommit: '是否提交或修改commit ?'
},
// prettier-ignore
types: [
{ value: "feat", name: "feat: 新增功能" },
{ value: "fix", name: "fix: 修复缺陷" },
{ value: "docs", name: "docs: 文档变更" },
{ value: "style", name: "style: 代码格式(不影响功能,例如空格、分号等格式修正)" },
{ value: "refactor", name: "refactor: 代码重构(不包括 bug 修复、功能新增)" },
{ value: "perf", name: "perf: 性能优化" },
{ value: "test", name: "test: 添加疏漏测试或已有测试改动" },
{ value: "build", name: "build: 构建流程、外部依赖变更(如升级 npm 包、修改 vite 配置等)" },
{ value: "ci", name: "ci: 修改 CI 配置、脚本" },
{ value: "revert", name: "revert: 回滚 commit" },
{ value: "chore", name: "chore: 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)" },
],
useEmoji: true,
emojiAlign: 'center',
useAI: false,
aiNumber: 1,
themeColorCode: '',
scopes: [],
allowCustomScopes: true,
allowEmptyScopes: true,
customScopesAlign: 'bottom',
customScopesAlias: 'custom',
emptyScopesAlias: 'empty',
upperCaseSubject: false,
markBreakingChangeMode: false,
allowBreakingChanges: ['feat', 'fix'],
breaklineNumber: 100,
breaklineChar: '|',
skipQuestions: ['breaking', 'footerPrefix', 'footer'], // 跳过的步骤
issuePrefixes: [{ value: 'closed', name: 'closed: ISSUES has been processed' }],
customIssuePrefixAlign: 'top',
emptyIssuePrefixAlias: 'skip',
customIssuePrefixAlias: 'custom',
allowCustomIssuePrefix: true,
allowEmptyIssuePrefix: true,
confirmColorize: true,
maxHeaderLength: Infinity,
maxSubjectLength: Infinity,
minSubjectLength: 0,
scopeOverrides: undefined,
defaultBody: '',
defaultIssues: '',
defaultScope: '',
defaultSubject: ''
}
}
+83
View File
@@ -0,0 +1,83 @@
// 从 URL 和路径模块中导入必要的功能
import fs from 'fs'
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
// 从 ESLint 插件中导入推荐配置
import pluginJs from '@eslint/js'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
// 使用 import.meta.url 获取当前模块的路径
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// 读取 .auto-import.json 文件的内容,并将其解析为 JSON 对象
const autoImportConfig = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '.auto-import.json'), 'utf-8')
)
export default [
// 指定文件匹配规则
{
files: ['**/*.{js,mjs,cjs,ts,tsx,vue}']
},
// 指定全局变量和环境
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
// 扩展配置
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/essential'],
// 自定义规则
{
// 针对所有 JavaScript、TypeScript 和 Vue 文件应用以下配置
files: ['**/*.{js,mjs,cjs,ts,tsx,vue}'],
languageOptions: {
globals: {
// 合并从 autoImportConfig 中读取的全局变量配置
...autoImportConfig.globals,
// TypeScript 全局命名空间
Api: 'readonly'
}
},
rules: {
quotes: ['error', 'single'], // 使用单引号
semi: ['error', 'never'], // 语句末尾不加分号
'no-var': 'error', // 要求使用 let 或 const 而不是 var
'@typescript-eslint/no-explicit-any': 'off', // 禁用 any 检查
'vue/multi-word-component-names': 'off', // 禁用对 Vue 组件名称的多词要求检查
'no-multiple-empty-lines': ['warn', { max: 1 }], // 不允许多个空行
'no-unexpected-multiline': 'error' // 禁止空余的多行
}
},
// vue 规则
{
files: ['**/*.vue'],
languageOptions: {
parserOptions: { parser: tseslint.parser }
}
},
// 忽略文件
{
ignores: [
'node_modules',
'dist',
'public',
'.vscode/**',
'src/assets/**',
'src/utils/console.ts'
]
},
// prettier 配置
eslintPluginPrettierRecommended
]
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html>
<head>
<title>Art Design Pro</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Art Design Pro - A modern admin dashboard template built with Vue 3, TypeScript, and Element Plus."
/>
<link rel="shortcut icon" type="image/x-icon" href="src/assets/images/favicon.ico" />
<style>
/* 防止页面刷新时白屏的初始样式 */
html {
background-color: #fafbfc;
}
html.dark {
background-color: #070707;
}
</style>
<script>
// 初始化 html class 主题属性
;(function () {
try {
if (typeof Storage === 'undefined' || !window.localStorage) {
return
}
const themeType = localStorage.getItem('sys-theme')
if (themeType === 'dark') {
document.documentElement.classList.add('dark')
}
} catch (e) {
console.warn('Failed to apply initial theme:', e)
}
})()
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
{
"name": "art-design-pro",
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=20.19.0",
"pnpm": ">=8.8.0"
},
"scripts": {
"dev": "vite --open",
"build": "vue-tsc --noEmit && vite build",
"serve": "vite preview",
"lint": "eslint",
"fix": "eslint --fix",
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix",
"lint:lint-staged": "lint-staged",
"commit": "git-cz",
"clean:dev": "tsx scripts/clean-dev.ts"
},
"config": {
"commitizen": {
"path": "node_modules/cz-git"
}
},
"lint-staged": {
"*.{js,ts,mjs,mts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{cjs,json,jsonc}": [
"prettier --write"
],
"*.vue": [
"eslint --fix",
"stylelint --fix --allow-empty-input",
"prettier --write"
],
"*.{html,htm}": [
"prettier --write"
],
"*.{scss,css,less}": [
"stylelint --fix --allow-empty-input",
"prettier --write"
],
"*.{md,mdx}": [
"prettier --write"
],
"*.{yaml,yml}": [
"prettier --write"
]
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@iconify/vue": "^5.0.0",
"@tailwindcss/vite": "^4.1.14",
"@vue/reactivity": "^3.5.21",
"@vueuse/core": "^13.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "next",
"axios": "^1.12.2",
"crypto-js": "^4.2.0",
"echarts": "^6.0.0",
"element-plus": "^2.11.2",
"file-saver": "^2.0.5",
"highlight.js": "^11.10.0",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"ohash": "^2.0.11",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.3.0",
"qrcode.vue": "^3.6.0",
"tailwindcss": "^4.1.14",
"vue": "^3.5.21",
"vue-draggable-plus": "^0.6.0",
"vue-i18n": "^9.14.0",
"vue-router": "^4.5.1",
"xgplayer": "^3.0.20",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@commitlint/cli": "^19.4.1",
"@commitlint/config-conventional": "^19.4.1",
"@eslint/js": "^9.9.1",
"@types/node": "^24.0.5",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/compiler-sfc": "^3.0.5",
"commitizen": "^4.3.0",
"cz-git": "^1.11.1",
"eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.27.0",
"globals": "^15.9.0",
"husky": "^9.1.5",
"lint-staged": "^15.5.2",
"prettier": "^3.5.3",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.81.0",
"stylelint": "^16.20.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^4.6.0",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard": "^36.0.1",
"terser": "^5.36.0",
"tsx": "^4.20.3",
"typescript": "~5.6.3",
"typescript-eslint": "^8.9.0",
"unplugin-auto-import": "^20.2.0",
"unplugin-element-plus": "^0.10.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.7.6",
"vue-demi": "^0.14.9",
"vue-img-cutter": "^3.0.5",
"vue-tsc": "~2.1.6"
}
}
+10109
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+838
View File
@@ -0,0 +1,838 @@
// scripts/clean-dev.ts
import fs from 'fs/promises'
import path from 'path'
// 现代化颜色主题
const theme = {
// 基础颜色
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
// 前景色
primary: '\x1b[38;5;75m', // 亮蓝色
success: '\x1b[38;5;82m', // 亮绿色
warning: '\x1b[38;5;220m', // 亮黄色
error: '\x1b[38;5;196m', // 亮红色
info: '\x1b[38;5;159m', // 青色
purple: '\x1b[38;5;141m', // 紫色
orange: '\x1b[38;5;208m', // 橙色
gray: '\x1b[38;5;245m', // 灰色
white: '\x1b[38;5;255m', // 白色
// 背景色
bgDark: '\x1b[48;5;235m', // 深灰背景
bgBlue: '\x1b[48;5;24m', // 蓝色背景
bgGreen: '\x1b[48;5;22m', // 绿色背景
bgRed: '\x1b[48;5;52m' // 红色背景
}
// 现代化图标集
const icons = {
rocket: '🚀',
fire: '🔥',
star: '⭐',
gem: '💎',
crown: '👑',
magic: '✨',
warning: '⚠️',
success: '✅',
error: '❌',
info: 'ℹ️',
folder: '📁',
file: '📄',
image: '🖼️',
code: '💻',
data: '📊',
globe: '🌐',
map: '🗺️',
chat: '💬',
bolt: '⚡',
shield: '🛡️',
key: '🔑',
link: '🔗',
clean: '🧹',
trash: '🗑️',
check: '✓',
cross: '✗',
arrow: '→',
loading: '⏳'
}
// 格式化工具
const fmt = {
title: (text: string) => `${theme.bold}${theme.primary}${text}${theme.reset}`,
subtitle: (text: string) => `${theme.purple}${text}${theme.reset}`,
success: (text: string) => `${theme.success}${text}${theme.reset}`,
error: (text: string) => `${theme.error}${text}${theme.reset}`,
warning: (text: string) => `${theme.warning}${text}${theme.reset}`,
info: (text: string) => `${theme.info}${text}${theme.reset}`,
highlight: (text: string) => `${theme.bold}${theme.white}${text}${theme.reset}`,
dim: (text: string) => `${theme.dim}${theme.gray}${text}${theme.reset}`,
orange: (text: string) => `${theme.orange}${text}${theme.reset}`,
// 带背景的文本
badge: (text: string, bg: string = theme.bgBlue) =>
`${bg}${theme.white}${theme.bold} ${text} ${theme.reset}`,
// 渐变效果模拟
gradient: (text: string) => {
const colors = ['\x1b[38;5;75m', '\x1b[38;5;81m', '\x1b[38;5;87m', '\x1b[38;5;159m']
const chars = text.split('')
return chars.map((char, i) => `${colors[i % colors.length]}${char}`).join('') + theme.reset
}
}
// 创建现代化标题横幅
function createModernBanner() {
console.log()
console.log(
fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗')
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
` ║ ${icons.rocket} ${fmt.title('ART DESIGN PRO')} ${fmt.subtitle('· 代码精简程序')} ${icons.magic} ║`
)
console.log(
` ║ ${fmt.dim('为项目移除演示数据,快速切换至开发模式')} ║`
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝')
)
console.log()
}
// 创建分割线
function createDivider(char = '─', color = theme.primary) {
console.log(`${color}${' ' + char.repeat(66)}${theme.reset}`)
}
// 创建卡片样式容器
function createCard(title: string, content: string[]) {
console.log(` ${fmt.badge('', theme.bgBlue)} ${fmt.title(title)}`)
console.log()
content.forEach((line) => {
console.log(` ${line}`)
})
console.log()
}
// 进度条动画
function createProgressBar(current: number, total: number, text: string, width = 40) {
const percentage = Math.round((current / total) * 100)
const filled = Math.round((current / total) * width)
const empty = width - filled
const filledBar = '█'.repeat(filled)
const emptyBar = '░'.repeat(empty)
process.stdout.write(
`\r ${fmt.info('进度')} [${theme.success}${filledBar}${theme.gray}${emptyBar}${theme.reset}] ${fmt.highlight(percentage + '%')})}`
)
if (current === total) {
console.log()
}
}
// 统计信息
const stats = {
deletedFiles: 0,
deletedPaths: 0,
failedPaths: 0,
startTime: Date.now(),
totalFiles: 0
}
// 清理目标
const targets = [
'README.md',
'README.zh-CN.md',
'CHANGELOG.md',
'CHANGELOG.zh-CN.md',
'src/views/change',
'src/views/safeguard',
'src/views/article',
'src/views/examples',
'src/views/system/nested',
'src/views/widgets',
'src/views/template',
'src/views/dashboard/analysis',
'src/views/dashboard/ecommerce',
'src/mock/json',
'src/mock/temp/articleList.ts',
'src/mock/temp/commentDetail.ts',
'src/mock/temp/commentList.ts',
'src/assets/images/cover',
'src/assets/images/safeguard',
'src/assets/images/3d',
'src/components/core/charts/art-map-chart',
'src/components/business/comment-widget'
]
// 递归统计文件数量
async function countFiles(targetPath: string): Promise<number> {
const fullPath = path.resolve(process.cwd(), targetPath)
try {
const stat = await fs.stat(fullPath)
if (stat.isFile()) {
return 1
} else if (stat.isDirectory()) {
const entries = await fs.readdir(fullPath)
let count = 0
for (const entry of entries) {
const entryPath = path.join(targetPath, entry)
count += await countFiles(entryPath)
}
return count
}
} catch {
return 0
}
return 0
}
// 统计所有目标的文件数量
async function countAllFiles(): Promise<number> {
let totalCount = 0
for (const target of targets) {
const count = await countFiles(target)
totalCount += count
}
return totalCount
}
// 删除文件和目录
async function remove(targetPath: string, index: number) {
const fullPath = path.resolve(process.cwd(), targetPath)
createProgressBar(index + 1, targets.length, targetPath)
try {
const fileCount = await countFiles(targetPath)
await fs.rm(fullPath, { recursive: true, force: true })
stats.deletedFiles += fileCount
stats.deletedPaths++
await new Promise((resolve) => setTimeout(resolve, 50))
} catch (err) {
stats.failedPaths++
console.log()
console.log(` ${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(targetPath)}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理路由模块
async function cleanRouteModules() {
const modulesPath = path.resolve(process.cwd(), 'src/router/modules')
try {
// 删除演示相关的路由模块
const modulesToRemove = [
'template.ts',
'widgets.ts',
'examples.ts',
'article.ts',
'safeguard.ts',
'help.ts'
]
for (const module of modulesToRemove) {
const modulePath = path.join(modulesPath, module)
try {
await fs.rm(modulePath, { force: true })
} catch {
// 文件不存在时忽略错误
}
}
// 重写 dashboard.ts - 只保留 console
const dashboardContent = `import { AppRouteRecord } from '@/types/router'
export const dashboardRoutes: AppRouteRecord = {
name: 'Dashboard',
path: '/dashboard',
component: '/index/index',
meta: {
title: 'menus.dashboard.title',
icon: 'ri:pie-chart-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'console',
name: 'Console',
component: '/dashboard/console',
meta: {
title: 'menus.dashboard.console',
keepAlive: false,
fixedTab: true
}
}
]
}
`
await fs.writeFile(path.join(modulesPath, 'dashboard.ts'), dashboardContent, 'utf-8')
// 重写 system.ts - 移除 nested 嵌套菜单
const systemContent = `import { AppRouteRecord } from '@/types/router'
export const systemRoutes: AppRouteRecord = {
path: '/system',
name: 'System',
component: '/index/index',
meta: {
title: 'menus.system.title',
icon: 'ri:user-3-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'user',
name: 'User',
component: '/system/user',
meta: {
title: 'menus.system.user',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'role',
name: 'Role',
component: '/system/role',
meta: {
title: 'menus.system.role',
keepAlive: true,
roles: ['R_SUPER']
}
},
{
path: 'user-center',
name: 'UserCenter',
component: '/system/user-center',
meta: {
title: 'menus.system.userCenter',
isHide: true,
keepAlive: true,
isHideTab: true
}
},
{
path: 'menu',
name: 'Menus',
component: '/system/menu',
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{ title: '新增', authMark: 'add' },
{ title: '编辑', authMark: 'edit' },
{ title: '删除', authMark: 'delete' }
]
}
}
]
}
`
await fs.writeFile(path.join(modulesPath, 'system.ts'), systemContent, 'utf-8')
// 重写 index.ts - 只导入保留的模块
const indexContent = `import { AppRouteRecord } from '@/types/router'
import { dashboardRoutes } from './dashboard'
import { systemRoutes } from './system'
import { resultRoutes } from './result'
import { exceptionRoutes } from './exception'
/**
* 导出所有模块化路由
*/
export const routeModules: AppRouteRecord[] = [
dashboardRoutes,
systemRoutes,
resultRoutes,
exceptionRoutes
]
`
await fs.writeFile(path.join(modulesPath, 'index.ts'), indexContent, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清理路由模块完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理路由模块失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理路由别名
async function cleanRoutesAlias() {
const routesAliasPath = path.resolve(process.cwd(), 'src/router/routesAlias.ts')
try {
const cleanedAlias = `/**
* 公共路由别名
# 存放系统级公共路由路径,如布局容器、登录页等
*/
export enum RoutesAlias {
Layout = '/index/index', // 布局容器
Login = '/auth/login' // 登录页
}
`
await fs.writeFile(routesAliasPath, cleanedAlias, 'utf-8')
console.log(` ${icons.success} ${fmt.success('重写路由别名配置完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理路由别名失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理变更日志
async function cleanChangeLog() {
const changeLogPath = path.resolve(process.cwd(), 'src/mock/upgrade/changeLog.ts')
try {
const cleanedChangeLog = `import { ref } from 'vue'
interface UpgradeLog {
version: string // 版本号
title: string // 更新标题
date: string // 更新日期
detail?: string[] // 更新内容
requireReLogin?: boolean // 是否需要重新登录
remark?: string // 备注
}
export const upgradeLogList = ref<UpgradeLog[]>([])
`
await fs.writeFile(changeLogPath, cleanedChangeLog, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清空变更日志数据完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理变更日志失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理语言文件
async function cleanLanguageFiles() {
const languageFiles = [
{ path: 'src/locales/langs/zh.json', name: '中文语言文件' },
{ path: 'src/locales/langs/en.json', name: '英文语言文件' }
]
for (const { path: langPath, name } of languageFiles) {
try {
const fullPath = path.resolve(process.cwd(), langPath)
const content = await fs.readFile(fullPath, 'utf-8')
const langData = JSON.parse(content)
const menusToRemove = [
'widgets',
'template',
'article',
'examples',
'safeguard',
'plan',
'help'
]
if (langData.menus) {
menusToRemove.forEach((menuKey) => {
if (langData.menus[menuKey]) {
delete langData.menus[menuKey]
}
})
if (langData.menus.dashboard) {
if (langData.menus.dashboard.analysis) {
delete langData.menus.dashboard.analysis
}
if (langData.menus.dashboard.ecommerce) {
delete langData.menus.dashboard.ecommerce
}
}
if (langData.menus.system) {
const systemKeysToRemove = [
'nested',
'menu1',
'menu2',
'menu21',
'menu3',
'menu31',
'menu32',
'menu321'
]
systemKeysToRemove.forEach((key) => {
if (langData.menus.system[key]) {
delete langData.menus.system[key]
}
})
}
}
await fs.writeFile(fullPath, JSON.stringify(langData, null, 2), 'utf-8')
console.log(` ${icons.success} ${fmt.success(`清理${name}完成`)}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error(`清理${name}失败`)}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
}
// 清理快速入口组件
async function cleanFastEnterComponent() {
const fastEnterPath = path.resolve(process.cwd(), 'src/config/fastEnter.ts')
try {
const cleanedFastEnter = `/**
* 快速入口配置
* 包含:应用列表、快速链接等配置
*/
import { WEB_LINKS } from '@/utils/constants'
import type { FastEnterConfig } from '@/types/config'
const fastEnterConfig: FastEnterConfig = {
// 显示条件(屏幕宽度)
minWidth: 1200,
// 应用列表
applications: [
{
name: '工作台',
description: '系统概览与数据统计',
icon: 'ri:pie-chart-line',
iconColor: '#377dff',
enabled: true,
order: 1,
routeName: 'Console'
},
{
name: '官方文档',
description: '使用指南与开发文档',
icon: 'ri:bill-line',
iconColor: '#ffb100',
enabled: true,
order: 2,
link: WEB_LINKS.DOCS
},
{
name: '技术支持',
description: '技术支持与问题反馈',
icon: 'ri:user-location-line',
iconColor: '#ff6b6b',
enabled: true,
order: 3,
link: WEB_LINKS.COMMUNITY
},
{
name: '哔哩哔哩',
description: '技术分享与交流',
icon: 'ri:bilibili-line',
iconColor: '#FB7299',
enabled: true,
order: 4,
link: WEB_LINKS.BILIBILI
}
],
// 快速链接
quickLinks: [
{
name: '登录',
enabled: true,
order: 1,
routeName: 'Login'
},
{
name: '注册',
enabled: true,
order: 2,
routeName: 'Register'
},
{
name: '忘记密码',
enabled: true,
order: 3,
routeName: 'ForgetPassword'
},
{
name: '个人中心',
enabled: true,
order: 4,
routeName: 'UserCenter'
}
]
}
export default Object.freeze(fastEnterConfig)
`
await fs.writeFile(fastEnterPath, cleanedFastEnter, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清理快速入口配置完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理快速入口配置失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 更新菜单接口
async function updateMenuApi() {
const apiPath = path.resolve(process.cwd(), 'src/api/system-manage.ts')
try {
const content = await fs.readFile(apiPath, 'utf-8')
const updatedContent = content.replace(
"url: '/api/v3/system/menus'",
"url: '/api/v3/system/menus/simple'"
)
await fs.writeFile(apiPath, updatedContent, 'utf-8')
console.log(` ${icons.success} ${fmt.success('更新菜单接口完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('更新菜单接口失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 用户确认函数
async function getUserConfirmation(): Promise<boolean> {
const { createInterface } = await import('readline')
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout
})
console.log(
` ${fmt.highlight('请输入')} ${fmt.success('yes')} ${fmt.highlight('确认执行清理操作,或按 Enter 取消')}`
)
console.log()
process.stdout.write(` ${icons.arrow} `)
rl.question('', (answer: string) => {
rl.close()
resolve(answer.toLowerCase().trim() === 'yes')
})
})
}
// 显示清理警告
async function showCleanupWarning() {
createCard('安全警告', [
`${fmt.warning('此操作将永久删除以下演示内容,且无法恢复!')}`,
`${fmt.dim('请仔细阅读清理列表,确认后再继续操作')}`
])
const cleanupItems = [
{
icon: icons.image,
name: '图片资源',
desc: '演示用的封面图片、3D图片、运维图片等',
color: theme.orange
},
{
icon: icons.file,
name: '演示页面',
desc: 'widgets、template、article、examples、safeguard等页面',
color: theme.purple
},
{
icon: icons.code,
name: '路由模块文件',
desc: '删除演示路由模块,只保留核心模块(dashboard、system、result、exception)',
color: theme.primary
},
{
icon: icons.link,
name: '路由别名',
desc: '重写routesAlias.ts,移除演示路由别名',
color: theme.info
},
{
icon: icons.data,
name: 'Mock数据',
desc: '演示用的JSON数据、文章列表、评论数据等',
color: theme.success
},
{
icon: icons.globe,
name: '多语言文件',
desc: '清理中英文语言包中的演示菜单项',
color: theme.warning
},
{ icon: icons.map, name: '地图组件', desc: '移除art-map-chart地图组件', color: theme.error },
{ icon: icons.chat, name: '评论组件', desc: '移除comment-widget评论组件', color: theme.orange },
{
icon: icons.bolt,
name: '快速入口',
desc: '移除分析页、礼花效果、聊天、更新日志、定价、留言管理等无效项目',
color: theme.purple
}
]
console.log(` ${fmt.badge('', theme.bgRed)} ${fmt.title('将要清理的内容')}`)
console.log()
cleanupItems.forEach((item, index) => {
console.log(` ${item.color}${theme.reset} ${fmt.highlight(`${index + 1}. ${item.name}`)}`)
console.log(` ${fmt.dim(item.desc)}`)
})
console.log()
console.log(` ${fmt.badge('', theme.bgGreen)} ${fmt.title('保留的功能模块')}`)
console.log()
const preservedModules = [
{ name: 'Dashboard', desc: '工作台页面' },
{ name: 'System', desc: '系统管理模块' },
{ name: 'Result', desc: '结果页面' },
{ name: 'Exception', desc: '异常页面' },
{ name: 'Auth', desc: '登录注册功能' },
{ name: 'Core Components', desc: '核心组件库' }
]
preservedModules.forEach((module) => {
console.log(` ${icons.check} ${fmt.success(module.name)} ${fmt.dim(`- ${module.desc}`)}`)
})
console.log()
createDivider()
console.log()
}
// 显示统计信息
async function showStats() {
const duration = Date.now() - stats.startTime
const seconds = (duration / 1000).toFixed(2)
console.log()
createCard('清理统计', [
`${fmt.success('成功删除')}: ${fmt.highlight(stats.deletedFiles.toString())} 个文件`,
`${fmt.info('涉及路径')}: ${fmt.highlight(stats.deletedPaths.toString())} 个目录/文件`,
...(stats.failedPaths > 0
? [
`${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(stats.failedPaths.toString())} 个路径`
]
: []),
`${fmt.info('耗时')}: ${fmt.highlight(seconds)} 秒`
])
}
// 创建成功横幅
function createSuccessBanner() {
console.log()
console.log(
fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗')
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
` ║ ${icons.star} ${fmt.success('清理完成!项目已准备就绪')} ${icons.rocket} ║`
)
console.log(
` ║ ${fmt.dim('现在可以开始您的开发之旅了!')} ║`
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝')
)
console.log()
}
// 主函数
async function main() {
// 清屏并显示横幅
console.clear()
createModernBanner()
// 显示清理警告
await showCleanupWarning()
// 统计文件数量
console.log(` ${fmt.info('正在统计文件数量...')}`)
stats.totalFiles = await countAllFiles()
console.log(` ${fmt.info('即将清理')}: ${fmt.highlight(stats.totalFiles.toString())} 个文件`)
console.log(` ${fmt.dim(`涉及 ${targets.length} 个目录/文件路径`)}`)
console.log()
// 用户确认
const confirmed = await getUserConfirmation()
if (!confirmed) {
console.log(` ${fmt.warning('操作已取消,清理中止')}`)
console.log()
return
}
console.log()
console.log(` ${icons.check} ${fmt.success('确认成功,开始清理...')}`)
console.log()
// 开始清理过程
console.log(` ${fmt.badge('步骤 1/6', theme.bgBlue)} ${fmt.title('删除演示文件')}`)
console.log()
for (let i = 0; i < targets.length; i++) {
await remove(targets[i], i)
}
console.log()
console.log(` ${fmt.badge('步骤 2/6', theme.bgBlue)} ${fmt.title('清理路由模块')}`)
console.log()
await cleanRouteModules()
console.log()
console.log(` ${fmt.badge('步骤 3/6', theme.bgBlue)} ${fmt.title('重写路由别名')}`)
console.log()
await cleanRoutesAlias()
console.log()
console.log(` ${fmt.badge('步骤 4/6', theme.bgBlue)} ${fmt.title('清空变更日志')}`)
console.log()
await cleanChangeLog()
console.log()
console.log(` ${fmt.badge('步骤 5/6', theme.bgBlue)} ${fmt.title('清理语言文件')}`)
console.log()
await cleanLanguageFiles()
console.log()
console.log(` ${fmt.badge('步骤 6/7', theme.bgBlue)} ${fmt.title('清理快速入口')}`)
console.log()
await cleanFastEnterComponent()
console.log()
console.log(` ${fmt.badge('步骤 7/7', theme.bgBlue)} ${fmt.title('更新菜单接口')}`)
console.log()
await updateMenuApi()
// 显示统计信息
await showStats()
// 显示成功横幅
createSuccessBanner()
}
main().catch((err) => {
console.log()
console.log(` ${icons.error} ${fmt.error('清理脚本执行出错')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
console.log()
process.exit(1)
})
+41
View File
@@ -0,0 +1,41 @@
<template>
<ElConfigProvider
size="default"
:locale="locales[language]"
:z-index="3000"
:card="{
shadow: 'never'
}"
>
<RouterView></RouterView>
</ElConfigProvider>
</template>
<script setup lang="ts">
import { useUserStore } from './store/modules/user'
import zh from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import { systemUpgrade } from './utils/sys'
import { toggleTransition } from './utils/ui/animation'
import { checkStorageCompatibility } from './utils/storage'
import { initializeTheme } from './hooks/core/useTheme'
const userStore = useUserStore()
const { language } = storeToRefs(userStore)
const locales = {
zh: zh,
en: en
}
onBeforeMount(() => {
toggleTransition(true)
initializeTheme()
})
onMounted(() => {
checkStorageCompatibility()
toggleTransition(false)
systemUpgrade()
})
</script>
+44
View File
@@ -0,0 +1,44 @@
import request from '@/utils/http'
/**
* 登录(Django 后端 /api/admin/auth/token)
*
* 适配说明:
* - 模板登录视图约定 LoginParams{userName} / LoginResponse{token, refreshToken},
* 后端实际接收 {username, password}、返回信封 {code, data: {access, refresh}};
* http 封装已解包信封(code===200 返回 data),此处只做字段映射。
* - SimpleJWT 要求 Authorization: Bearer <token>,而模板拦截器原样设置
* accessToken,故存入 store 前拼上 Bearer 前缀(模板期望的 token 形态)。
*/
export async function fetchLogin(params: Api.Auth.LoginParams) {
const data = await request.post<{ access: string; refresh: string }>({
url: '/admin/auth/token',
params: { username: params.userName, password: params.password }
})
return {
token: `Bearer ${data.access}`,
refreshToken: data.refresh
} satisfies Api.Auth.LoginResponse
}
/**
* 获取用户信息(Django 后端 /api/admin/auth/me)
*
* 后端仅返回 {username, is_admin},映射为模板 UserInfo 结构:
* - userName ← username;email/avatar 留空(模板有静态兜底头像)
* - roles ← is_admin ? ['R_ADMIN'] : ['R_USER'](空数组会跳过菜单角色过滤,
* 非 staff 用户将看到全部菜单入口,故兜底 R_USER)
* - buttons ← [](按钮级权限占位);userId ← 0(仅用于多用户工作台清理判断)
*/
export async function fetchGetUserInfo(): Promise<Api.Auth.UserInfo> {
const data = await request.get<{ username: string; is_admin: boolean }>({
url: '/admin/auth/me'
})
return {
buttons: [],
roles: data.is_admin ? ['R_ADMIN'] : ['R_USER'],
userId: 0,
userName: data.username,
email: ''
}
}
+26
View File
@@ -0,0 +1,26 @@
import request from '@/utils/http'
import { AppRouteRecord } from '@/types/router'
// 获取用户列表
export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) {
return request.get<Api.SystemManage.UserList>({
url: '/user/list',
params
})
}
// 获取角色列表
export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) {
return request.get<Api.SystemManage.RoleList>({
url: '/role/list',
params
})
}
// 获取菜单列表
// 注意:后端无此端点,frontend 模式下死代码;切 backend 模式前需先实现
export function fetchGetMenuList() {
return request.get<AppRouteRecord[]>({
url: '/v3/system/menus/simple'
})
}
+77
View File
@@ -0,0 +1,77 @@
import request from '@/utils/http'
/**
* 总览 API(server/tracker/api/dashboard.py,客迹视觉 V1 形状)
*
* 周期口径全排除爬虫;site 参数传 site_id,不传 = 聚合全部站点
* (站点不存在返回 404 site_not_found);range 非法值后端回落 7d。
*/
/** 周期枚举:today=上海自然日;7d/30d=近 N 天窗口 */
export type OverviewRange = 'today' | '7d' | '30d'
/** 页头在线信息(active_now 是 online 的兼容别名,二者恒相等) */
export interface OverviewHeader {
online: number
active_now: number
}
/** 周期卡指标;delta_pct = 与上一个等长周期比较的百分比(today 比昨天),
* 上期值为 0 时为 null(前端显示 —) */
export interface CardMetric {
value: number
delta_pct: number | null
}
/** 四张周期卡:访客数 / 访问次数 / 平均有效停留(ms) / 回访访客 */
export interface OverviewCards {
visitors: CardMetric
visits: CardMetric
avg_stay_ms: CardMetric
returning: CardMetric
}
/** 趋势点(日粒度:历史位为 DailyStat 聚合、缺日补零,今天一位为实时值) */
export interface TrendPoint {
date: string
uv: number
sessions: number
}
/** 渠道聚合行:channel 为 CHANNELS 枚举(含 direct/unknown);
* pct = 占已分类会话比(4 位小数,列表内总和 ≈ 1;无分类行的历史会话不计入) */
export interface ChannelItem {
channel: string
sessions: number
pct: number
}
/** 总览响应 */
export interface OverviewResp {
header: OverviewHeader
cards: OverviewCards
trend: TrendPoint[]
channels: ChannelItem[]
}
/** 总览数据(site 传 site_id 可选;range 默认 7d) */
export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) =>
request.get<OverviewResp>({ url: '/admin/dashboard/overview', params })
/** 漏斗级行(label 为服务端基词;末级「邮件已发」前端追加「(未验证)」限定语——T3 审查备忘) */
export interface FunnelLevel {
/** 级别事件名:page_view/contact_click/form_start/form_submit/form_submit_result */
key: string
label: string
/** 窗口内发生过该事件的 distinct 会话数(五级逐级独立口径,非嵌套) */
count: number
}
/** 询盘漏斗响应(五级,无百分比——前端只做计数条渲染) */
export interface FunnelSummaryResp {
levels: FunnelLevel[]
}
/** 询盘漏斗(days=1 当日 / 7 近 7 天,上海自然日窗口;排除爬虫;site 可选) */
export const getFunnelSummary = (days: number, site?: string) =>
request.get<FunnelSummaryResp>({ url: '/admin/funnel-summary', params: { days, site } })
+43
View File
@@ -0,0 +1,43 @@
import request from '@/utils/http'
/**
* 站点 API(server/tracker/api/sites.py)
*
* 域名后端归一(去协议/端口/路径、转小写、去空去重);DELETE 为软删(停用),仅 admin。
* 模板 http 封装暴露 get/post/put/del/request——无 patch 方法,PATCH 走通用 request 指定 method。
*/
/** 站点行(sites.py `_serialize`) */
export interface SiteRow {
id: number
name: string
site_id: string
domains: string[]
allow_no_origin: boolean
is_active: boolean
created_at: string
}
/** 创建站点参数(name 必填,domains 归一后为空返回 400 invalid_domains) */
export interface CreateSiteData {
name: string
domains: string[]
allow_no_origin?: boolean
}
/** 更新站点参数(Partial,仅传要改的字段) */
export type UpdateSiteData = Partial<Pick<SiteRow, 'name' | 'domains' | 'allow_no_origin' | 'is_active'>>
/** 站点列表(按 created_at 倒序) */
export const getSites = () => request.get<SiteRow[]>({ url: '/admin/sites' })
/** 创建站点 */
export const createSite = (data: CreateSiteData) =>
request.post<SiteRow>({ url: '/admin/sites', data })
/** 更新站点(PATCH:模板封装未暴露 patch,走通用 request) */
export const updateSite = (id: number, data: UpdateSiteData) =>
request.request<SiteRow>({ url: `/admin/sites/${id}`, method: 'PATCH', data })
/** 停用站点(DELETE 软删:is_active=False,上报侧 collect 即 403) */
export const deactivateSite = (id: number) => request.del<SiteRow>({ url: `/admin/sites/${id}` })
+243
View File
@@ -0,0 +1,243 @@
import request from '@/utils/http'
/**
* 访客 API(server/tracker/api/visitors.py)
*
* 字段对照说明:
* - stay 相关只有 stay_ms(有效停留口径)——duration_ms 已从 API 移除,前端不得引用
* - 空白字符串字段(country/os/browser 等)后端以 '' 序列化,不为 null
*/
/** 访客行(visitors.py `_row`:列表/详情共用行字段) */
export interface VisitorRow {
visitor_id: string
/** 站点归属(多站点下分辨访客来自哪个站) */
site_id: string
site_name: string
country: string
province: string
city: string
device_type: string
/** 设备型号(UA-CH model,如 Pixel 7;未采集/晚到前为空串) */
device_model: string
os: string
browser: string
language: string
first_seen_at: string
last_seen_at: string
visit_count: number
active_days: number
session_count: number
/** 会话 stay_ms 累计(无会话时后端补 0) */
total_stay_ms: number
first_source: string
last_source: string
/** 首次访问落地页(空串=未采集,如直达首页;列表 tooltip / 详情识别卡用) */
first_landing_path: string
/** 最近一条事件的 path(总览「浏览页面」列;无事件访客为空串) */
last_path: string
/** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */
channel: string
platform: string
variant: string
search_term: string
resolution: string
online: boolean
}
/** 会话摘要(visitors.py `_session_summary`) */
export interface SessionSummary {
session_id: string
started_at: string
ended_at: string
referrer_domain: string
utm: Record<string, string>
landing_path: string
event_count: number
stay_ms: number
}
/** 单条来源识别(visitors.py `_classification`:classify() 结果 + 规则版本/计算时间) */
export interface AttributionInfo {
/** CHANNELS 枚举:natural_search/paid_search/paid_social/ai_referral/social/in_app/referral/direct/unknown */
channel: string
/** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */
platform: string
/** 国家站变体(如 google.ru 的 "ru") */
variant: string
/** 搜索词(仅自然搜索可能有值) */
search_term: string
/** observed_referrer / utm_label / combined / observed_ua */
evidence_kind: string
/** resolved / label_only / conflict / unknown */
resolution: string
/** 规则侧附加证据(conflict 时含 referrer_evidence/utm_evidence 并列)——只做文本渲染,禁 v-html */
detail: Record<string, unknown>
/** 生成该分类的规则版本(attribution.py RULE_VERSION) */
rule_version: string
computed_at: string
/** 仅 session_entries 携带(first_touch 无会话归属,不带该键) */
session_id?: string | null
}
/** 首次可观测来源占位(无 first_touch 分类行的历史访客,后端仅给 channel/resolution 两字段) */
export interface ObservablePlaceholder {
channel: 'unknown'
resolution: 'unknown'
}
/** 详情归因块:first_touch 主口径 + 来源证据三口径 + 近几次会话入口识别 */
export interface AttributionBlock {
/** 首触分类(旧数据未重算时为 null) */
first_touch: AttributionInfo | null
/** 首次可观测来源:first_touch 分类原样(含 direct/unknown);无分类行 → unknown 占位 */
first_observable: AttributionInfo | ObservablePlaceholder
/** 首次可识别来源:first_touch 可识别时用之,否则最早一条可识别 session_entry;无则 null */
first_identifiable: AttributionInfo | null
/** 本次会话来源:最新一条 session_entry;无则 null */
latest_session: AttributionInfo | null
session_entries: AttributionInfo[]
}
/** 访客详情(`_row` + 详情扩展字段 + 全量会话摘要,M1 不分页) */
export interface VisitorDetail extends VisitorRow {
screen: string
timezone_name: string
/** 扩展环境快照(EXTENDED_ENV_KEYS 白名单键,值均为字符串;旧数据为空对象——逐键判空跳行) */
env_extra: Record<string, string>
/** GenericIPAddressField(null=True),未采集到时为 null */
last_ip: string | null
first_utm: Record<string, string>
first_referrer_domain: string
/** page_view 事件数(page_stay 是停留信号不并计) */
pages_viewed: number
/** 来源归因块(first_touch=null 时前端回落 first_source 展示) */
attribution: AttributionBlock
sessions: SessionSummary[]
}
/** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文;
* 业务事件额外携带 `_safe_fields` 安全字段——均为可选,旧数据/无该字段时不出现) */
export interface TimelineEvent {
time: string
event_name: string
path: string
title: string
/** page_view 合并后为累计毫秒(初始 0);独立成条的 page_stay 为毫秒;其余事件为 null */
active_ms: number | null
/** contact_click 咨询渠道:whatsapp / phone / email */
channel?: string
/** form_start / form_submit 表单标识 */
form_id?: string
/** form_submit 提交批次号前 8 位(服务端截断,非全量 ID) */
submission_id?: string
/** form_error 错误码(如 validation_failed) */
error_code?: string
/** form_error 未通过的表单字段数 */
field_count?: number
/** form_submit_result 投递结果:mail_sent / mail_failed */
result_code?: string
/** product_view 产品标识 */
product_id?: string
/** product_view 是否 URL 推导(非埋点显式上报) */
inferred?: boolean
}
/** 时间线单会话分组(session 为 NULL 的孤立事件并成一组:session_id/started_at 均为 null) */
export interface TimelineSession {
session_id: string | null
started_at: string | null
events: TimelineEvent[]
}
/** 时间线单日(日期组倒序,日内会话/事件正序) */
export interface TimelineDay {
date: string
sessions: TimelineSession[]
}
/** 时间线响应(truncated 区分"没动静"和"被截断") */
export interface TimelineResp {
days: TimelineDay[]
truncated: boolean
}
/** 访客列表响应 */
export interface VisitorListResp {
list: VisitorRow[]
total: number
page: number
}
/** 原始上报批次行(visitors.py `VisitorRawBatchesView`) */
export interface RawBatchRow {
id: number
batch_id: string
/** accepted / partial / rejected */
status: string
event_count: number
rejected_count: number
received_at: string
/** 脱敏后的整包 JSON 文本(敏感值已 [FILTERED]、被拒批次已截断)——只做文本渲染,禁 v-html */
body: string
}
/** 原始批次列表响应 */
export interface RawBatchListResp {
list: RawBatchRow[]
total: number
page: number
}
/**
* 访客列表筛选参数。
* online/is_new/show_bot 是后端字符串哨兵(仅 "1" 生效,见 visitors.py `_page_visitors`)——
* 传 boolean true 会被序列化成 "true" 而被静默忽略(假筛选),类型层面直接锁死。
*/
export interface VisitorListParams {
page?: number
/** 站点过滤(site_id;不传 = 全部站点,站点不存在时后端 404 site_not_found) */
site?: string
country?: string
device_type?: string
/** 仅 "1" 生效:5 分钟内有行为的在线访客 */
online?: '1'
/** 仅 "1" 生效:今天首次到访的新访客 */
is_new?: '1'
/** 仅 "1" 生效:包含爬虫(默认排除) */
show_bot?: '1'
/** visitor_id / last_ip 前缀模糊匹配 */
search?: string
/** 渠道筛选(CHANNELS 枚举:natural_search/paid_search/paid_social/ai_referral/social/in_app/referral/direct/unknown,
* 按访客 first_touch 分类过滤;无分类行的历史访客不属于任何渠道桶) */
channel?: string
/** YYYY-MM-DD(非法值后端宽松忽略) */
date_from?: string
/** YYYY-MM-DD */
date_to?: string
}
/** 访客列表(筛选参数见 VisitorListParams) */
export const getVisitors = (params: VisitorListParams) =>
request.get<VisitorListResp>({ url: '/admin/visitors', params })
/** 访客详情(404 时后端返回 visitor_not_found) */
export const getVisitor = (visitorId: string) =>
request.get<VisitorDetail>({ url: `/admin/visitors/${visitorId}` })
/** 跨天时间线(days 后端夹紧 1-90,默认 30) */
export const getTimeline = (visitorId: string, days = 30) =>
request.get<TimelineResp>({ url: `/admin/visitors/${visitorId}/timeline`, params: { days } })
/** 单会话详情(会话摘要 + 事件序列,同一套 page_view/page_stay 合并规则) */
export const getSession = (visitorId: string, sessionId: string) =>
request.get<SessionSummary & { events: TimelineEvent[] }>({
url: `/admin/visitors/${visitorId}/sessions/${sessionId}`
})
/** 访客原始上报批次(page_size 10,按接收时间倒序;404 时后端返回 visitor_not_found) */
export const getRawBatches = (visitorId: string, page = 1) =>
request.get<RawBatchListResp>({
url: `/admin/visitors/${visitorId}/raw-batches`,
params: { page }
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="94" y="34" width="212" height="233"><path d="M306 34H94v233h212V34Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M234.427 155.64h38.36V69.6h-38.36v86.04ZM113.326 155.64h121.1V69.6h-121.1v86.04Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.126 155.354h104.2v-72.95h-104.2v72.95ZM236.369 71.05s0 3.3 1.65 5.05c2.33 2.52 7.38-.2 7.38-.2s-1.75 5.15-1.55 10.19c.29 8.24 6.99 9.51 10 4.75 4.56 4.85 8.94-.29 9.52-2.62 4.27 4.76 9.32-.87 9.32-.87v-6.3l-23.99-12.13-12.33 2.13Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M234.429 155.641h-121.1l-15.93 32.11h121.1l15.93-32.11Z" fill="#fff"/><path d="M234.427 69.6h38.46v86.04M113.326 146.52V69.6h121.1M234.429 155.641l-15.93 32.11h-121.1l15.93-32.11h111.39" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M226.37 159.715H116.82l-12.04 23.86H215l11.37-23.86Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="m288.807 187.751-15.92-32.11h-38.46l16.02 32.11h38.36Z" fill="#fff"/><path d="m238.607 163.981 11.84 23.77h38.36l-15.92-32.11h-38.46" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M207.336 223.734c-3.69-13.77-15.44-23.86-29.33-23.86h-8.65s-27.09 14.94-27.09 33.27c0 18.34 25.44 33.18 25.44 33.18h10.4c13.79-.1 25.44-10.19 29.13-23.87 1.75-12.51 0-18.62.1-18.72Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M243.459 240.421c3.98 0 7.28-3.3 7.28-7.27 0-3.98-3.3-7.28-7.28-7.28h-31.08c-3.98 0-7.28 3.3-7.28 7.28 0 3.97 3.3 7.27 7.28 7.27h31.08Z" fill="#C7DEFF"/><path d="M210.342 223.737c-4.08-13.87-16.9-23.96-32.05-23.96H168.972s-29.62 14.94-29.62 33.37 27.87 33.37 27.87 33.37h11.27c15.05-.1 27.77-10.19 31.75-23.96" stroke="#071F4D"/><path d="M212.379 240.421c-3.98 0-7.28-3.3-7.28-7.27m0 0c0-3.98 3.3-7.28 7.28-7.28" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" fill="#006EFF"/><path d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.775 209.38c-13.14 0-23.79 10.64-23.79 23.77 0 13.12 10.65 23.76 23.79 23.76 13.14 0 23.8-10.64 23.8-23.76 0-13.13-10.66-23.77-23.8-23.77Z" fill="#00E4E5"/><path d="M162.174 223.736a17.48 17.48 0 0 1 14.76-8.05M159.455 231.982c.1-1.36.29-2.62.68-3.88" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M173.535 209.87c-1.55-.3-3.11-.49-4.76-.49-13.11 0-23.79 10.67-23.79 23.77 0 13.09 10.68 23.76 23.79 23.76 1.65 0 3.21-.19 4.76-.48-10.88-2.23-19.03-11.84-19.03-23.28 0-11.45 8.15-21.05 19.03-23.28Z" fill="#071F4D"/><path d="M219.957 225.774h23.6c4.08 0 7.38 3.3 7.38 7.37m0 0c0 4.08-3.3 7.37-7.38 7.37h-20.1M212.091 225.774h3.3" stroke="#071F4D"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" fill="#fff"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" stroke="#071F4D"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6l2.04-9.6Z" fill="#fff"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6" stroke="#071F4D"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63l2.04-8.63Z" fill="#fff"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63M147.801 34.485v34.92M121.775 34.485v34.92M102.546 204.724v13.97M102.546 222.379v.87M102.546 197.934v3.49M115.268 206.955v26.29M115.268 239.451v5.34M244.43 197.643v11.93M244.43 213.939v3.49M270.359 201.232v33.76M115.369 47.774h-13.6M94.486 47.774h3.4M241.516 47.774h-84.1M280.168 47.774h25.35" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m282.497 183.575-12.04-23.86h-27.29l11.36 23.86h27.97Z" fill="#00E4E5"/><path d="M234.427 134.88V69.6M234.427 140.412v7.66" stroke="#071F4D"/><path d="M220.831 228.684h16.99M240.934 228.684h2.43" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="m223.842 187.462 21.46-.2-10.97-20.66-10.49 20.86Z" fill="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg
viewBox="0 0 400 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="47" y="38" width="307" height="224"><path d="M353.3 38H47.5v223.8h305.8V38Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M299.2 200.6H61.6v5.1h240.3l-2.7-5.1Z" fill="#C7DEFF"/><path d="m308.9 185.8-6.5 20H183.7M332.3 127.6h10.6l-5 16.7-14.8-.1-7.2 21.1M328.8 127.4l13.6-39.6M307.6 166 337 84.7H180.6l-9.8 26.9h-10.5M296.6 196l4.3-11.8M157.2 149.2l6.4-17.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-34.8 95.8h136.4l34.7-95.8ZM169.9 166.2l5-13.6-5 13.6Z" fill="#fff"/><path d="m169.9 166.2 5-13.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-4 11.7h135.8l4.5-11.7Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M102.6 159.5h38.3l2.7 36.6h-38.4c-10.1 0-20.9-8.2-20.9-18.3 0-10.1 8.2-18.3 18.3-18.3Z" fill="#DEEBFC"/><path fill-rule="evenodd" clip-rule="evenodd" d="M84.3 174.102c2.5 3.4 10 5 17.9 2.8 16.6-6.5 23.8-3.9 23.8-3.9s.5-3.4 1.3-5c-5.8-3-15.4.3-26.1 3.1-10.7 2.8-15.8-2.5-15.8-2.5-.4 0-1.1 2.8-1.1 5.5Z" fill="#fff"/><path d="M96.5 194.2c-7.2-3.3-12.2-10.5-12.2-19m0 0c0-11.5 9.3-20.8 20.8-20.8h29.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8l14.5 19.8Zm-14.5-19.8c0-11.5 9.3-20.8 20.8-20.8l-20.8 20.8Zm20.8-20.8c11.5 0 20.8 9.3 20.8 20.8l-20.8-20.8Zm20.8 20.8c0 8.4-5 15.6-12.1 18.9l12.1-18.9Z" fill="#fff"/><path d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8m0 0c0-11.5 9.3-20.8 20.8-20.8m0 0c11.5 0 20.8 9.3 20.8 20.8m0 0c0 8.4-5 15.6-12.1 18.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.5 177.2c0-7.7-6.3-14-14-14s-14 6.3-14 14c0 5.8 3.5 10.8 8.6 12.9.1 0 5.8 1.6 10.7 0 5.3-1.7 8.7-7.1 8.7-12.9Z" fill="#00E4E5"/><path d="M140.5 190.1c-5.8-2.4-9.9-8.2-9.9-14.9 0-8.9 7.2-16.1 16.1-16.1 8.9 0 16.1 7.2 16.1 16.1 0 6.8-4.2 12.5-10.1 14.9M88.4 170.604c2.9 1.3 7.7 2.6 13.6.3 14.7-5.7 22.3-4.3 24.6-3.5M84.5 174.599s5.9 6.5 19 1.7c9.2-3.4 15.3-3.9 18.8-3.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M340.6 112.3h-55.2l-2.7 6.2H338l2.6-6.2Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M236.8 117.9c-16.13 0-29.2 13.07-29.2 29.2s13.07 29.2 29.2 29.2 29.2-13.07 29.2-29.2-13.07-29.2-29.2-29.2Z" fill="#00E4E5"/><path d="M265 123.3c13.1 13.1 13.1 34.4 0 47.6M306 205.9h19.2M61.7 205.9h32.9M181.2 196.2h115.2M47.5 205.9h10v-9.7h73.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M146.7 179.2c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M169.5 196.2c3.9 0 7.1 3.2 7.1 7.1 0 3.9-3.2 7.1-7.1 7.1H144c-2.1 0-3.9 1.7-3.9 3.9v1c0 2.1 1.7 3.9 3.9 3.9h48c5.1 0 9.2 4.1 9.2 9.2s-4.1 9.3-9.2 9.2h-33.8c-2.3 0-4.1 1.8-4.1 4.1s1.8 4.1 4.1 4.1h4.2c4.4 0 8 3.6 8 8s-3.6 8-8 8H111c-3.7 0-6.8-3-6.8-6.8 0-3.7 3-6.8 6.8-6.8h.3c2.3 0 4.1-1.8 4.1-4.1s-1.8-4.1-4.1-4.1H79c-4.5 0-8.1-3.6-8.1-8.1s3.6-8.1 8.1-8.1h37.7c2.1 0 3.9-1.7 3.9-3.9 0-2.1-1.7-3.9-3.9-3.9h-7.9c-4.4 0-7.9-3.5-7.9-7.9s3.5-7.9 7.9-7.9h30.4c2.2 0 3.9-1.8 3.9-3.9V187c0-1.9 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5v5.3c0 2.2 1.8 3.9 3.9 3.9h15.5Z" fill="#006EFF"/><path d="m227.8 138.5 18.7 18.7M227.8 157.2l18.7-18.7" stroke="#fff" stroke-width="6"/><path fill-rule="evenodd" clip-rule="evenodd" d="M194.8 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8ZM202.9 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8Z" fill="#fff"/><path d="m291.7 184.3-1.6 4.6h-121M298.1 166.7l22.5-61.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m193 134.1 2.2-5.1h-19.4l-2.3 5.1H193ZM313.2 123.5l2.2-5.1h-24.5l-2.3 5.1h24.6Z" fill="#DEEBFC"/><path d="m164.5 159.2 19.8-54.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M199.6 119.8h-53.2l-4.4 9.3h53.2l4.4-9.3Z" fill="#00E4E5"/><path d="M151.3 129.1H142l4.4-9.3h16.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M353.3 169.4h-67.4l-4.8 12.2h67.3l4.9-12.2Z" fill="#006EFF"/><path d="M332.4 169.4h20.9l-4.9 12.2h-39.7M242.7 235.5v-4.8c0-3.8 3.1-7 7-7h20.2c3.8 0 7 3.1 7 7" stroke="#071F4D"/><path d="M261.1 235.5v-4.8c0-3.8 3.1-7 7-7h13.7c3.8 0 7 3.1 7 7v4.8M242.6 230.7h13.7M235.2 237.7h63.3M224 237.7h6.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.1 141.3H335l3.3-10.7h-10.2l-4 10.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M288.3 230.4c0-3.6-2.9-6.5-6.5-6.5h-14.2c-3.6 0-6.5 2.9-6.5 6.5v5.3h27.2v-5.3Z" fill="#071F4D"/><path d="M80.4 228.5H83M87.7 228.5h19.2M146.3 195.8v2c0 3.6-2.9 6.6-6.6 6.6H138M133.4 204.3h1.5M154 249.9h9.4" stroke="#DEEBFC"/><path d="m299.4 141.9 5.1-13.9" stroke="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -0,0 +1 @@
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="44" y="42" width="312" height="217"><path d="M355.3 42H44v216.9h311.3V42Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M288.2 248.4h25.1v-30h-25.1v30Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M304.498 238.199c-1.5-3.9-5.9-15.4-4-21.6-2.9.8-3.3.1-5-.1-1.7-.1 0 10.7 2.2 16.4 1.7 4.5 2.1 11.1 2.1 13.6h5.4c.2-1.9.3-5.5-.7-8.3Z" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6M290.2 214.7h21.4c1 0 1.8.8 1.8 1.8v29" stroke="#071F4D" stroke-width="1.096"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" fill="#fff"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" stroke="#071F4D" stroke-width="1.096"/><path d="M295.402 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3M300.502 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m331 258.4-.3-5.2H88.5l-1.2 5.2H331Z" fill="#C7DEFF"/><path d="M252.9 248.7H331M216.6 258.4H331M47.1 139.3l-2.6 1.5 42.7 117.6h129.2v-6.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" fill="#fff"/><path d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" stroke="#071F4D"/><path d="m203.2 153.2 32.2 88.7H97.8l-32.3-88.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M72.2 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4ZM79.3 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M263.5 171.2h80.3v-63.7h-80.3v63.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M290 143.9h-45.6l12.5 51.3H290v-51.3Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M286 117.4h-29.3v77.8h92.9v-67.6l-55.9.6-7.7-10.8Z" fill="#00E4E5"/><path d="m332.6 127.6-38.9.6-7.7-10.8h-11.7M308.9 195.2h45.9M250.3 195.2h28.5M287.3 195.2h12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.5 211.4H186v-44h-55.5v44Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M148.7 192.5h-31.6l8.7 35.5h22.9v-35.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M145.9 174.2h-20.2V228h64.1v-46.7l-38.6.4-5.3-7.5Z" fill="#006EFF"/><path d="m179 181.3-27.8.4-5.3-7.5h-7.7M176.2 201.7h19.2M163.2 210.7H195M172.1 228h-54.2M184.8 228h8.1M174.9 228h5.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m293.2 155.7-6.4 6.3 15.3 15.3 22.7-22.6-6.4-6.4-16.3 16.3-8.9-8.9Z" fill="#fff"/><path d="M57.2 258.4h283.6M345.9 258.4h8.1M55.4 258.4h220.5M160.1 118.8l-1.2 2.7M156.7 127c-.3.8-.7 1.8-1.1 2.8M222 68.5c-1 .2-1.9.5-2.9.8M214.1 70.7c-5.8 1.9-11.3 4.4-16.5 7.4M195.4 79.5c-.9.5-1.7 1.1-2.5 1.6M314.2 98.5c-.6-.8-1.3-1.5-2-2.3M308.9 92.8c-4-4-8.3-7.6-13-10.8M293.9 80.7c-.8-.5-1.7-1.1-2.5-1.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.296 71.203c-3.6-1.5-18.5-2.9-21.8-1.9-1 5.8 4.9 13.5 4.9 13.5s6-9.9 16.9-11.6Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.3 42.704c-6.5 6.7-7.8 13-8.8 19.3 24.4-1.1 36.3 13 42.8 20 3.2-9.1 7.8-23 7.2-29-7.1-6.4-20-11.7-41.2-10.3Z" fill="#C7DEFF"/><path d="M230 69.3c36.2-3.8 52 21.1 52 21.1s11.4-28.2 10.5-37.4c-7.3-6.5-23.3-12-45.6-10.1-9 6.3-15.6 18.7-16.9 26.4Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.604 70.7c-6 8.4-9.9 21.9-8.8 33.8 8.4 5.3 32.3 10.5 43.6 11.5 6.1-7.9 15.9-26 15.9-26s-32-4.8-50.7-19.3Z" fill="#C7DEFF"/><path d="M193.103 119.5c4.8-2.7 19.2-29.5 19.2-29.5s-35.8-5.4-53.7-21.8c-9.3 6.1-16.4 24.3-15 40.1 10.6 6.7 45.8 13.3 49.5 11.2Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M189.5 111.6c-3 5.2-5.7 7.2-9.8 6.6 12.2 2.6 13.5 1.2 15.6-1.1 2.2-2.4 4.2-6.6 4.2-6.6s-3.1 2.5-10 1.1Z" fill="#071F4D"/><path d="M331 251.8v6.6M77 165.4l-2.7-6.7h7.8M222.8 228.9l2.8 6.6h-7.9" stroke="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+292
View File
@@ -0,0 +1,292 @@
// 全局样式
// 顶部进度条颜色
#nprogress .bar {
z-index: 2400;
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
}
#nprogress .peg {
box-shadow:
0 0 10px var(--theme-color),
0 0 5px var(--theme-color) !important;
}
#nprogress .spinner-icon {
border-top-color: var(--theme-color) !important;
border-left-color: var(--theme-color) !important;
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
* {
cursor: default !important;
}
}
// 背景滤镜
*,
::before,
::after {
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
// 色弱模式
.color-weak {
filter: invert(80%);
-webkit-filter: invert(80%);
}
#noop {
display: none;
}
// 语言切换选中样式
.langDropDownStyle {
// 选中项背景颜色
.is-selected {
background-color: var(--art-el-active-color) !important;
}
// 语言切换按钮菜单样式优化
.lang-btn-item {
.el-dropdown-menu__item {
padding-left: 13px !important;
padding-right: 6px !important;
margin-bottom: 3px !important;
}
&:last-child {
.el-dropdown-menu__item {
margin-bottom: 0 !important;
}
}
.menu-txt {
min-width: 60px;
display: block;
}
i {
font-size: 10px;
margin-left: 10px;
}
}
}
// 盒子默认边框
.page-content {
border: 1px solid var(--art-card-border) !important;
}
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
background: var(--default-box-color);
border: 1px solid #{$border-color} !important;
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
box-shadow: #{$shadow} !important;
--el-card-border-color: var(--default-border) !important;
}
.art-card,
.art-card-sm,
.art-card-xs {
border: 1px solid var(--art-card-border);
}
// 盒子边框
[data-box-mode='border-mode'] {
.page-content,
.art-table-card {
border: 1px solid var(--art-card-border) !important;
}
.art-card {
@include art-card-base(var(--art-card-border), none, 4px);
}
.art-card-sm {
@include art-card-base(var(--art-card-border), none, 0px);
}
.art-card-xs {
@include art-card-base(var(--art-card-border), none, -4px);
}
}
// 盒子阴影
[data-box-mode='shadow-mode'] {
.page-content,
.art-table-card {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
border: 1px solid var(--art-gray-200) !important;
}
.layout-sidebar {
border-right: 1px solid var(--art-card-border) !important;
}
.art-card {
@include art-card-base(
var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
4px
);
}
.art-card-sm {
@include art-card-base(
var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
2px
);
}
.art-card-xs {
@include art-card-base(
var(--art-gray-200),
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
-4px
);
}
}
// 元素全屏
.el-full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100vw !important;
height: 100% !important;
z-index: 2300;
margin-top: 0;
padding: 15px;
box-sizing: border-box;
background-color: var(--default-box-color);
display: flex;
flex-direction: column;
}
// 表格卡片
.art-table-card {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 12px;
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
.el-card__body {
height: 100%;
overflow: hidden;
}
}
// 容器全高
.art-full-height {
height: var(--art-full-height);
display: flex;
flex-direction: column;
@media (max-width: 640px) {
height: auto;
}
}
// 徽章样式
.art-badge {
position: absolute;
top: 0;
right: 20px;
bottom: 0;
width: 6px;
height: 6px;
margin: auto;
background: #ff3860;
border-radius: 50%;
animation: breathe 1.5s ease-in-out infinite;
&.art-badge-horizontal {
right: 0;
}
&.art-badge-mixed {
right: 0;
}
&.art-badge-dual {
right: 5px;
top: 5px;
bottom: auto;
}
}
// 文字徽章样式
.art-text-badge {
position: absolute;
top: 0;
right: 12px;
bottom: 0;
min-width: 20px;
height: 18px;
line-height: 17px;
padding: 0 5px;
margin: auto;
font-size: 10px;
color: #fff;
text-align: center;
background: #fd4e4e;
border-radius: 4px;
}
@keyframes breathe {
0% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
100% {
opacity: 0.7;
transform: scale(1);
}
}
// 修复老机型 loading 定位问题
.art-loading-fix {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.art-loading-fix .el-loading-spinner {
position: static !important;
top: auto !important;
left: auto !important;
transform: none !important;
}
// 去除移动端点击背景色
@media screen and (max-width: 1180px) {
* {
-webkit-tap-highlight-color: transparent;
}
}
+93
View File
@@ -0,0 +1,93 @@
/*
* 深色主题
* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class')
*/
$font-color: rgba(#ffffff, 0.85);
/* 覆盖element-plus默认深色背景色 */
html.dark {
// element-plus
--el-bg-color: var(--default-box-color);
--el-text-color-regular: #{$font-color};
// 富文本编辑器
// 工具栏背景颜色
--w-e-toolbar-bg-color: #18191c;
// 输入区域背景颜色
--w-e-textarea-bg-color: #090909;
// 工具栏文字颜色
--w-e-toolbar-color: var(--art-gray-600);
// 选中菜单颜色
--w-e-toolbar-active-bg-color: #25262b;
// 弹窗边框颜色
--w-e-toolbar-border-color: var(--default-border-dashed);
// 分割线颜色
--w-e-textarea-border-color: var(--default-border-dashed);
// 链接输入框边框颜色
--w-e-modal-button-border-color: var(--default-border-dashed);
// 表格头颜色
--w-e-textarea-slight-bg-color: #090909;
// 按钮背景颜色
--w-e-modal-button-bg-color: #090909;
// hover toolbar 背景颜色
--w-e-toolbar-active-color: var(--art-gray-800);
}
.dark {
.page-content .article-list .item .left .outer > div {
border-right-color: var(--dark-border-color) !important;
}
// 富文本编辑器
.editor-wrapper {
*:not(pre code *) {
color: inherit !important;
}
}
// 分隔线
.w-e-bar-divider {
background-color: var(--art-gray-300) !important;
}
.w-e-select-list,
.w-e-drop-panel,
.w-e-bar-item-group .w-e-bar-item-menus-container,
.w-e-text-container [data-slate-editor] pre > code {
border: 1px solid var(--default-border) !important;
}
// 下拉选择框
.w-e-select-list {
background-color: var(--default-box-color) !important;
}
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover,
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: #090909 !important;
}
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
background-color: #25262b !important;
text-shadow: none !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
border-left: 4px solid var(--default-border-dashed) !important;
background-color: var(--art-color);
}
.editor-wrapper {
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid var(--default-border-dashed) !important;
}
.w-e-modal {
background-color: var(--art-color);
}
}
}
@@ -0,0 +1,2 @@
// 导入暗黑主题
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
@@ -0,0 +1,34 @@
// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss
// 自定义Element 亮色主题
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: (
'white': #ffffff,
'black': #000000,
'success': (
'base': #13deb9
),
'warning': (
'base': #ffae1f
),
'danger': (
'base': #ff4d4f
),
'error': (
'base': #fa896b
)
),
$button: (
'hover-bg-color': var(--el-color-primary-light-9),
'hover-border-color': var(--el-color-primary),
'border-color': var(--el-color-primary),
'text-color': var(--el-color-primary)
),
$messagebox: (
'border-radius': '12px'
),
$popover: (
'padding': '14px',
'border-radius': '10px'
)
);
+519
View File
@@ -0,0 +1,519 @@
// 优化 Element Plus 组件库默认样式
:root {
// 系统主色
--main-color: var(--el-color-primary);
--el-color-white: white !important;
--el-color-black: white !important;
// 输入框边框颜色
// --el-border-color: #E4E4E7 !important; // DCDFE6
// 按钮粗度
--el-font-weight-primary: 400 !important;
--el-component-custom-height: 36px !important;
--el-component-size: var(--el-component-custom-height) !important;
// 边框、按钮圆角...
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
color: var(--theme-color);
}
}
// 优化 el-form-item 标签高度
.el-form-item__label {
height: var(--el-component-custom-height) !important;
line-height: var(--el-component-custom-height) !important;
}
// 日期选择器
.el-date-range-picker {
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
}
// el-card 背景色跟系统背景色保持一致
html.dark .el-card {
--el-card-bg-color: var(--default-box-color) !important;
}
// 修改 el-pagination 大小
.el-pagination--default {
& {
--el-pagination-button-width: 32px !important;
--el-pagination-button-height: var(--el-pagination-button-width) !important;
}
@media (max-width: 1180px) {
& {
--el-pagination-button-width: 28px !important;
}
}
.el-select--default .el-select__wrapper {
min-height: var(--el-pagination-button-width) !important;
}
.el-pagination__jump .el-input {
height: var(--el-pagination-button-width) !important;
}
}
.el-pager li {
padding: 0 10px !important;
// border: 1px solid red !important;
}
// 优化菜单折叠展开动画(提升动画流畅度)
.el-menu.el-menu--inline {
transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
// 优化菜单 item hover 动画(提升鼠标跟手感)
.el-sub-menu__title,
.el-menu-item {
transition: background-color 0s !important;
}
// -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
// 修改 el-button 高度
.el-button--default {
height: var(--el-component-custom-height) !important;
}
// circle 按钮宽度优化
.el-button--default.is-circle {
width: var(--el-component-custom-height) !important;
}
// 修改 el-select 高度
.el-select--default {
.el-select__wrapper {
min-height: var(--el-component-custom-height) !important;
}
}
// 修改 el-checkbox-button 高度
.el-checkbox-button--default .el-checkbox-button__inner,
// 修改 el-radio-button 高度
.el-radio-button--default .el-radio-button__inner {
padding: 10px 15px !important;
}
// -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
.el-pagination.is-background .btn-next,
.el-pagination.is-background .btn-prev,
.el-pagination.is-background .el-pager li {
border-radius: 6px;
}
.el-popover {
min-width: 80px;
border-radius: var(--el-border-radius-small) !important;
}
.el-dialog {
border-radius: 100px !important;
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
overflow: hidden;
}
.el-dialog__header {
.el-dialog__title {
font-size: 16px;
}
}
.el-dialog__body {
padding: 25px 0 !important;
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
}
.el-dialog.el-dialog-border {
.el-dialog__body {
// 上边框
&::before,
// 下边框
&::after {
content: '';
position: absolute;
left: -16px;
width: calc(100% + 32px);
height: 1px;
background-color: var(--art-gray-300);
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
}
}
// el-message 样式优化
.el-message {
background-color: var(--default-box-color) !important;
border: 0 !important;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
p {
font-size: 13px;
}
}
// 修改 el-dropdown 样式
.el-dropdown-menu {
padding: 6px !important;
border-radius: 10px !important;
border: none !important;
.el-dropdown-menu__item {
padding: 6px 16px !important;
border-radius: 6px !important;
&:hover:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-el-active-color) !important;
}
&:focus:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-gray-200) !important;
}
}
}
// 隐藏 select、dropdown 的三角
.el-select__popper,
.el-dropdown__popper {
margin-top: -6px !important;
.el-popper__arrow {
display: none;
}
}
.el-dropdown-selfdefine:focus {
outline: none !important;
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
.el-message-box,
.el-dialog {
width: calc(100% - 24px) !important;
}
.el-date-picker.has-sidebar.has-time {
width: calc(100% - 24px);
left: 12px !important;
}
.el-picker-panel *[slot='sidebar'],
.el-picker-panel__sidebar {
display: none;
}
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
.el-picker-panel__sidebar + .el-picker-panel__body {
margin-left: 0;
}
}
// 修改el-button样式
.el-button {
&.el-button--text {
background-color: transparent !important;
padding: 0 !important;
span {
margin-left: 0 !important;
}
}
}
// 修改el-tag样式
.el-tag {
font-weight: 500;
transition: all 0s !important;
&.el-tag--default {
height: 26px !important;
}
}
.el-checkbox-group {
&.el-table-filter__checkbox-group label.el-checkbox {
height: 17px !important;
.el-checkbox__label {
font-weight: 400 !important;
}
}
}
.el-radio--default {
// 优化单选按钮大小
.el-radio__input {
.el-radio__inner {
width: 16px;
height: 16px;
&::after {
width: 6px;
height: 6px;
}
}
}
}
.el-checkbox {
.el-checkbox__inner {
border-radius: 2px !important;
}
}
// 优化复选框样式
.el-checkbox--default {
.el-checkbox__inner {
width: 16px !important;
height: 16px !important;
border-radius: 4px !important;
&::before {
content: '';
height: 4px !important;
top: 5px !important;
background-color: #fff !important;
transform: scale(0.6) !important;
}
}
.is-checked {
.el-checkbox__inner {
&::after {
width: 3px;
height: 8px;
margin: auto;
border: 2px solid var(--el-checkbox-checked-icon-color);
border-left: 0;
border-top: 0;
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
transform-origin: center;
}
}
}
}
.el-notification .el-notification__icon {
font-size: 22px !important;
}
// 修改 el-message-box 样式
.el-message-box__headerbtn .el-message-box__close,
.el-dialog__headerbtn .el-dialog__close {
top: 7px;
right: 7px;
width: 30px;
height: 30px;
border-radius: 5px;
transition: all 0.3s;
&:hover {
background-color: var(--art-hover-color) !important;
color: var(--art-gray-900) !important;
}
}
.el-message-box {
padding: 25px 20px !important;
}
.el-message-box__title {
font-weight: 500 !important;
}
.el-table__column-filter-trigger i {
color: var(--theme-color) !important;
margin: -3px 0 0 2px;
}
// 去除 el-dropdown 鼠标放上去出现的边框
.el-tooltip__trigger:focus-visible {
outline: unset;
}
// ipad 表单右侧按钮优化
@media screen and (max-width: 1180px) {
.el-table-fixed-column--right {
padding-right: 0 !important;
}
}
.login-out-dialog {
padding: 30px 20px !important;
border-radius: 10px !important;
}
// 修改 dialog 动画
.dialog-fade-enter-active {
.el-dialog:not(.is-draggable) {
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
// 修复 el-dialog 动画后宽度不自适应问题
.el-select__selected-item {
display: inline-block;
}
}
}
.dialog-fade-leave-active {
animation: fade-out 0.2s linear;
.el-dialog:not(.is-draggable) {
animation: dialog-close 0.5s;
}
}
@keyframes dialog-open {
0% {
opacity: 0;
transform: scale(0.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-close {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.2);
}
}
// 遮罩层动画
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
// 修改 el-select 样式
.el-select__popper:not(.el-tree-select__popper) {
.el-select-dropdown__list {
padding: 5px !important;
.el-select-dropdown__item {
height: 34px !important;
line-height: 34px !important;
border-radius: 6px !important;
&.is-selected {
color: var(--art-gray-900) !important;
font-weight: 400 !important;
background-color: var(--art-el-active-color) !important;
margin-bottom: 4px !important;
}
&:hover {
background-color: var(--art-hover-color) !important;
}
}
.el-select-dropdown__item:hover ~ .is-selected,
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
background-color: transparent !important;
}
}
}
// 修改 el-tree-select 样式
.el-tree-select__popper {
.el-select-dropdown__list {
padding: 5px !important;
.el-tree-node {
.el-tree-node__content {
height: 36px !important;
border-radius: 6px !important;
&:hover {
background-color: var(--art-gray-200) !important;
}
}
}
}
}
// 实现水波纹在文字下面效果
.el-button > span {
position: relative;
z-index: 10;
}
// 优化颜色选择器圆角
.el-color-picker__color {
border-radius: 2px !important;
}
// 优化日期时间选择器底部圆角
.el-picker-panel {
.el-picker-panel__footer {
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
}
}
// 优化树型菜单样式
.el-tree-node__content {
border-radius: 4px;
margin-bottom: 4px;
padding: 1px 0;
&:hover {
background-color: var(--art-hover-color) !important;
}
}
.dark {
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background-color: var(--art-gray-300) !important;
}
}
// 隐藏折叠菜单弹窗 hover 出现的边框
.menu-left-popper:focus-within,
.horizontal-menu-popper:focus-within {
box-shadow: none !important;
outline: none !important;
}
// 数字输入组件右侧按钮高度跟随自定义组件高度
.el-input-number--default.is-controls-right {
.el-input-number__decrease,
.el-input-number__increase {
height: calc((var(--el-component-size) / 2)) !important;
}
}
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
// sass 混合宏(函数)
/**
* 溢出省略号
* @param {Number} 行数
*/
@mixin ellipsis($rowCount: 1) {
@if $rowCount <=1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: $rowCount;
-webkit-box-orient: vertical;
}
}
/**
* 控制用户能否选中文本
* @param {String} 类型
*/
@mixin userSelect($value: none) {
user-select: $value;
-moz-user-select: $value;
-ms-user-select: $value;
-webkit-user-select: $value;
}
// 绝对定位居中
@mixin absoluteCenter() {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
}
/**
* css3动画
*
*/
@mixin animation(
$from: (
width: 0px
),
$to: (
width: 100px
),
$name: mymove,
$animate: mymove 2s 1 linear infinite
) {
-webkit-animation: $animate;
-o-animation: $animate;
animation: $animate;
@keyframes #{$name} {
from {
@each $key, $value in $from {
#{$key}: #{$value};
}
}
to {
@each $key, $value in $to {
#{$key}: #{$value};
}
}
}
@-webkit-keyframes #{$name} {
from {
@each $key, $value in $from {
$key: $value;
}
}
to {
@each $key, $value in $to {
$key: $value;
}
}
}
}
// 圆形盒子
@mixin circle($size: 11px, $bg: #fff) {
border-radius: 50%;
width: $size;
height: $size;
line-height: $size;
text-align: center;
background: $bg;
}
// placeholder
@mixin placeholder($color: #bbb) {
// Firefox
&::-moz-placeholder {
color: $color;
opacity: 1;
}
// Internet Explorer 10+
&:-ms-input-placeholder {
color: $color;
}
// Safari and Chrome
&::-webkit-input-placeholder {
color: $color;
}
&:placeholder-shown {
text-overflow: ellipsis;
}
}
//背景透明,文字不透明。兼容IE8
@mixin betterTransparentize($color, $alpha) {
$c: rgba($color, $alpha);
$ie_c: ie_hex_str($c);
background: rgba($color, 1);
background: $c;
background: transparent \9;
zoom: 1;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
}
//添加浏览器前缀
@mixin browserPrefix($propertyName, $value) {
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
#{$prefix}#{$propertyName}: $value;
}
}
// 边框
@mixin border($color: red) {
border: 1px solid $color;
}
// 背景滤镜
@mixin backdropBlur() {
--tw-backdrop-blur: blur(30px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast)
var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert)
var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
+41
View File
@@ -0,0 +1,41 @@
@charset "UTF-8";
/*滚动条*/
/*滚动条整体部分,必须要设置*/
::-webkit-scrollbar {
width: 8px !important;
height: 0 !important;
}
/*滚动条的轨道*/
::-webkit-scrollbar-track {
background-color: var(--art-gray-200);
}
/*滚动条的滑块按钮*/
::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: #cccccc !important;
transition: all 0.2s;
-webkit-transition: all 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background-color: #b0abab !important;
}
/*滚动条的上下两端的按钮*/
::-webkit-scrollbar-button {
height: 0px;
width: 0;
}
.dark {
::-webkit-scrollbar-track {
background-color: var(--default-bg-color);
}
::-webkit-scrollbar-thumb {
background-color: var(--art-gray-300) !important;
}
}
@@ -0,0 +1,104 @@
@use 'sass:map';
// === 变量区域 ===
$transition: (
// 动画持续时间
duration: 0.25s,
// 滑动动画的移动距离
distance: 15px,
// 默认缓动函数
easing: cubic-bezier(0.25, 0.1, 0.25, 1),
// 淡入淡出专用的缓动函数
fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
);
// 抽取配置值函数,提高可复用性
@function transition-config($key) {
@return map.get($transition, $key);
}
// 变量简写
$duration: transition-config('duration');
$distance: transition-config('distance');
$easing: transition-config('easing');
$fade-easing: transition-config('fade-easing');
// === 动画类 ===
// 淡入淡出动画
.fade {
&-enter-active,
&-leave-active {
transition: opacity $duration $fade-easing;
will-change: opacity;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
&-enter-to,
&-leave-from {
opacity: 1;
}
}
// 滑动动画通用样式
@mixin slide-transition($direction) {
$distance-x: 0;
$distance-y: 0;
@if $direction == 'left' {
$distance-x: -$distance;
} @else if $direction == 'right' {
$distance-x: $distance;
} @else if $direction == 'top' {
$distance-y: -$distance;
} @else if $direction == 'bottom' {
$distance-y: $distance;
}
&-enter-active {
transition:
opacity $duration $easing,
transform $duration $easing;
will-change: opacity, transform;
}
&-leave-active {
transition:
opacity calc($duration * 0.7) $easing,
transform calc($duration * 0.7) $easing;
will-change: opacity, transform;
}
&-enter-from {
opacity: 0;
transform: translate3d($distance-x, $distance-y, 0);
}
&-enter-to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
&-leave-to {
opacity: 0;
transform: translate3d(-$distance-x, -$distance-y, 0);
}
}
// 滑动动画方向类
.slide-left {
@include slide-transition('left');
}
.slide-right {
@include slide-transition('right');
}
.slide-top {
@include slide-transition('top');
}
.slide-bottom {
@include slide-transition('bottom');
}
+208
View File
@@ -0,0 +1,208 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* ==================== Light Mode Variables ==================== */
:root {
/* Base Colors */
--art-color: #ffffff;
--theme-color: var(--main-color);
/* Theme Colors - OKLCH Format */
--art-primary: oklch(0.7 0.23 260);
--art-secondary: oklch(0.72 0.19 231.6);
--art-error: oklch(0.73 0.15 25.3);
--art-info: oklch(0.58 0.03 254.1);
--art-success: oklch(0.78 0.17 166.1);
--art-warning: oklch(0.78 0.14 75.5);
--art-danger: oklch(0.68 0.22 25.3);
/* Gray Scale - Light Mode */
--art-gray-100: #f9fafb;
--art-gray-200: #f2f4f5;
--art-gray-300: #e6eaeb;
--art-gray-400: #dbdfe1;
--art-gray-500: #949eb7;
--art-gray-600: #7987a1;
--art-gray-700: #4d5875;
--art-gray-800: #383853;
--art-gray-900: #323251;
/* Border Colors */
--art-card-border: rgba(0, 0, 0, 0.08);
--default-border: #e2e8ee;
--default-border-dashed: #dbdfe9;
/* Background Colors */
--default-bg-color: #fafbfc;
--default-box-color: #ffffff;
/* Hover Color */
--art-hover-color: #edeff0;
/* Active Color */
--art-active-color: #f2f4f5;
/* Element Component Active Color */
--art-el-active-color: #f2f4f5;
}
/* ==================== Dark Mode Variables ==================== */
.dark {
/* Base Colors */
--art-color: #000000;
/* Gray Scale - Dark Mode */
--art-gray-100: #110f0f;
--art-gray-200: #17171c;
--art-gray-300: #393946;
--art-gray-400: #505062;
--art-gray-500: #73738c;
--art-gray-600: #8f8fa3;
--art-gray-700: #ababba;
--art-gray-800: #c7c7d1;
--art-gray-900: #e3e3e8;
/* Border Colors */
--art-card-border: rgba(255, 255, 255, 0.08);
--default-border: rgba(255, 255, 255, 0.1);
--default-border-dashed: #363843;
/* Background Colors */
--default-bg-color: #070707;
--default-box-color: #161618;
/* Hover Color */
--art-hover-color: #252530;
/* Active Color */
--art-active-color: #202226;
/* Element Component Active Color */
--art-el-active-color: #2e2e38;
}
/* ==================== Tailwind Theme Configuration ==================== */
@theme {
/* Box Color (Light: white / Dark: black) */
--color-box: var(--default-box-color);
/* System Theme Color */
--color-theme: var(--theme-color);
/* Hover Color */
--color-hover-color: var(--art-hover-color);
/* Active Color */
--color-active-color: var(--art-active-color);
/* Active Color */
--color-el-active-color: var(--art-active-color);
/* ElementPlus Theme Colors */
--color-primary: var(--art-primary);
--color-secondary: var(--art-secondary);
--color-error: var(--art-error);
--color-info: var(--art-info);
--color-success: var(--art-success);
--color-warning: var(--art-warning);
--color-danger: var(--art-danger);
/* Gray Scale Colors (Auto-adapts to dark mode) */
--color-g-100: var(--art-gray-100);
--color-g-200: var(--art-gray-200);
--color-g-300: var(--art-gray-300);
--color-g-400: var(--art-gray-400);
--color-g-500: var(--art-gray-500);
--color-g-600: var(--art-gray-600);
--color-g-700: var(--art-gray-700);
--color-g-800: var(--art-gray-800);
--color-g-900: var(--art-gray-900);
}
/* ==================== Custom Border Radius Utilities ==================== */
@utility rounded-custom-xs {
border-radius: calc(var(--custom-radius) / 2);
}
@utility rounded-custom-sm {
border-radius: calc(var(--custom-radius) / 2 + 2px);
}
/* ==================== Custom Utility Classes ==================== */
@layer utilities {
/* Flexbox Layout Utilities */
.flex-c {
@apply flex items-center;
}
.flex-b {
@apply flex justify-between;
}
.flex-cc {
@apply flex items-center justify-center;
}
.flex-cb {
@apply flex items-center justify-between;
}
/* Transition Utilities */
.tad-200 {
@apply transition-all duration-200;
}
.tad-300 {
@apply transition-all duration-300;
}
/* Border Utilities */
.border-full-d {
@apply border border-[var(--default-border)];
}
.border-b-d {
@apply border-b border-[var(--default-border)];
}
.border-t-d {
@apply border-t border-[var(--default-border)];
}
.border-l-d {
@apply border-l border-[var(--default-border)];
}
.border-r-d {
@apply border-r border-[var(--default-border)];
}
/* Cursor Utilities */
.c-p {
@apply cursor-pointer;
}
}
/* ==================== Custom Component Classes ==================== */
@layer components {
/* Art Card Header Component */
.art-card-header {
@apply flex justify-between pr-6 pb-1;
.title {
h4 {
@apply text-lg font-medium text-g-900;
}
p {
@apply mt-1 text-sm text-g-600;
span {
@apply ml-2 font-medium;
}
}
}
}
}
@@ -0,0 +1,63 @@
// 定义基础变量
$bg-animation-color-light: #000;
$bg-animation-color-dark: #fff;
$bg-animation-duration: 0.5s;
html {
--bg-animation-color: $bg-animation-color-light;
&.dark {
--bg-animation-color: $bg-animation-color-dark;
}
// View transition styles
&::view-transition-old(*) {
animation: none;
}
&::view-transition-new(*) {
animation: clip $bg-animation-duration ease-in both;
}
&::view-transition-old(root) {
z-index: 1;
}
&::view-transition-new(root) {
z-index: 9999;
}
&.dark {
&::view-transition-old(*) {
animation: clip $bg-animation-duration ease-in reverse both;
}
&::view-transition-new(*) {
animation: none;
}
&::view-transition-old(root) {
z-index: 9999;
}
&::view-transition-new(root) {
z-index: 1;
}
}
}
// 定义动画
@keyframes clip {
from {
clip-path: circle(0% at var(--x) var(--y));
}
to {
clip-path: circle(var(--r) at var(--x) var(--y));
}
}
// body 相关样式
body {
background-color: var(--bg-animation-color);
}
@@ -0,0 +1,11 @@
// 主题切换过渡优化,优化除视觉上的不适感
.theme-change {
* {
transition: 0s !important;
}
.el-switch__core,
.el-switch__action {
transition: all 0.3s !important;
}
}
@@ -0,0 +1,98 @@
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
color: #a6accd;
}
.hljs-string,
.hljs-section,
.hljs-selector-class,
.hljs-template-variable,
.hljs-deletion {
color: #aed07e !important;
}
.hljs-comment,
.hljs-quote {
color: #6f747d;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #c792ea;
}
.hljs-section,
.hljs-name,
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #c86068;
}
.hljs-literal {
color: #56b6c2;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-attribute,
.hljs-meta-string {
color: #abb2bf;
}
.hljs-attribute {
color: #c792ea;
}
.hljs-function {
color: #c792ea;
}
.hljs-type {
color: #f07178;
}
.hljs-title {
color: #82aaff !important;
}
.hljs-built_in,
.hljs-class {
color: #82aaff;
}
// 括号
.hljs-params {
color: #a6accd;
}
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #de7e61;
}
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id {
color: #61aeee;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}
+23
View File
@@ -0,0 +1,23 @@
// 重置默认样式
@use './core/reset.scss';
// 应用全局样式
@use './core/app.scss';
// Element Plus 样式优化
@use './core/el-ui.scss';
// Element Plus 暗黑主题
@use './core/el-dark.scss';
// 暗黑主题样式优化
@use './core/dark.scss';
// 路由切换动画
@use './core/router-transition';
// 主题切换过渡优化
@use './core/theme-change.scss';
// 主题切换圆形扩散动画
@use './core/theme-animation.scss';
+32
View File
@@ -0,0 +1,32 @@
// 自定义四点旋转SVG
export const fourDotsSpinnerSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<style>
.spinner {
transform-origin: 20px 20px;
animation: rotate 1.6s linear infinite;
}
.dot {
fill: var(--theme-color);
animation: fade 1.6s infinite;
}
.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: 0.5s; }
.dot:nth-child(3) { animation-delay: 1s; }
.dot:nth-child(4) { animation-delay: 1.5s; }
@keyframes rotate {
100% { transform: rotate(360deg); }
}
@keyframes fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
<g class="spinner">
<circle class="dot" cx="20" cy="8" r="4"/>
<circle class="dot" cx="32" cy="20" r="4"/>
<circle class="dot" cx="20" cy="32" r="4"/>
<circle class="dot" cx="8" cy="20" r="4"/>
</g>
</svg>
`
@@ -0,0 +1,343 @@
<!-- 基础横幅组件 -->
<template>
<div
class="art-card basic-banner"
:class="[{ 'has-decoration': decoration }, boxStyle]"
:style="{ height }"
@click="emit('click')"
>
<!-- 流星效果 -->
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
<span
v-for="(meteor, index) in meteors"
:key="index"
class="meteor"
:style="{
top: '-60px',
left: `${meteor.x}%`,
animationDuration: `${meteor.speed}s`,
animationDelay: `${meteor.delay}s`
}"
></span>
</div>
<div class="basic-banner__content">
<!-- title slot -->
<slot name="title">
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p>
</slot>
<!-- subtitle slot -->
<slot name="subtitle">
<p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{
subtitle
}}</p>
</slot>
<!-- button slot -->
<slot name="button">
<div
v-if="buttonConfig?.show"
class="basic-banner__button"
:style="{
backgroundColor: buttonColor,
color: buttonTextColor,
borderRadius: buttonRadius
}"
@click.stop="emit('buttonClick')"
>
{{ buttonConfig?.text }}
</div>
</slot>
<!-- default slot -->
<slot></slot>
<!-- background image -->
<img
v-if="imageConfig.src"
class="basic-banner__background-image"
:src="imageConfig.src"
:style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }"
loading="lazy"
alt="背景图片"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore)
defineOptions({ name: 'ArtBasicBanner' })
// 流星对象接口定义
interface Meteor {
/** 流星的水平位置(百分比) */
x: number
/** 流星划过的速度 */
speed: number
/** 流星出现的延迟时间 */
delay: number
}
// 按钮配置接口定义
interface ButtonConfig {
/** 是否启用按钮 */
show: boolean
/** 按钮文本 */
text: string
/** 按钮背景色 */
color?: string
/** 按钮文字颜色 */
textColor?: string
/** 按钮圆角大小 */
radius?: string
}
// 流星效果配置接口定义
interface MeteorConfig {
/** 是否启用流星效果 */
enabled: boolean
/** 流星数量 */
count?: number
}
// 背景图片配置接口定义
interface ImageConfig {
/** 图片源地址 */
src: string
/** 图片宽度 */
width?: string
/** 距底部距离 */
bottom?: string
/** 距右侧距离 */
right?: string // 距右侧距离
}
// 组件属性接口定义
interface Props {
/** 横幅高度 */
height?: string
/** 标题文本 */
title?: string
/** 副标题文本 */
subtitle?: string
/** 盒子样式 */
boxStyle?: string
/** 是否显示装饰效果 */
decoration?: boolean
/** 按钮配置 */
buttonConfig?: ButtonConfig
/** 流星配置 */
meteorConfig?: MeteorConfig
/** 图片配置 */
imageConfig?: ImageConfig
/** 标题颜色 */
titleColor?: string
/** 副标题颜色 */
subtitleColor?: string
}
// 组件属性默认值设置
const props = withDefaults(defineProps<Props>(), {
height: '11rem',
titleColor: 'white',
subtitleColor: 'white',
boxStyle: '!bg-theme/60',
decoration: true,
buttonConfig: () => ({
show: true,
text: '查看',
color: '#fff',
textColor: '#333',
radius: '6px'
}),
meteorConfig: () => ({ enabled: false, count: 10 }),
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
})
// 定义组件事件
const emit = defineEmits<{
(e: 'click'): void // 整体点击事件
(e: 'buttonClick'): void // 按钮点击事件
}>()
// 计算按钮样式属性
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
// 流星数据初始化
const meteors = ref<Meteor[]>([])
onMounted(() => {
if (props.meteorConfig?.enabled) {
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
}
})
/**
* 生成流星数据数组
* @param count 流星数量
* @returns 流星数据数组
*/
function generateMeteors(count: number): Meteor[] {
// 计算每个流星的区域宽度
const segmentWidth = 100 / count
return Array.from({ length: count }, (_, index) => {
// 计算流星起始位置
const segmentStart = index * segmentWidth
// 在区域内随机生成x坐标
const x = segmentStart + Math.random() * segmentWidth
// 随机决定流星速度快慢
const isSlow = Math.random() > 0.5
return {
x,
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
delay: Math.random() * 5
}
})
}
</script>
<style lang="scss" scoped>
.basic-banner {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 2rem;
overflow: hidden;
color: white;
border-radius: calc(var(--custom-radius) + 2px) !important;
&__content {
position: relative;
z-index: 1;
}
&__title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
}
&__subtitle {
position: relative;
z-index: 10;
margin: 0 0 1.5rem;
font-size: 0.9rem;
opacity: 0.9;
}
&__button {
box-sizing: border-box;
display: inline-block;
min-width: 80px;
height: var(--el-component-custom-height);
padding: 0 12px;
font-size: 14px;
line-height: var(--el-component-custom-height);
text-align: center;
cursor: pointer;
user-select: none;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
&__background-image {
position: absolute;
right: 0;
bottom: -3rem;
z-index: 0;
width: 12rem;
}
&.has-decoration::after {
position: absolute;
right: -10%;
bottom: -20%;
width: 60%;
height: 140%;
content: '';
background: rgb(255 255 255 / 10%);
border-radius: 30%;
transform: rotate(-20deg);
}
&__meteors {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
.meteor {
position: absolute;
width: 2px;
height: 60px;
background: linear-gradient(
to top,
rgb(255 255 255 / 40%),
rgb(255 255 255 / 10%),
transparent
);
opacity: 0;
transform-origin: top left;
animation-name: meteor-fall;
animation-timing-function: linear;
animation-iteration-count: infinite;
&::before {
position: absolute;
right: 0;
bottom: 0;
width: 2px;
height: 2px;
content: '';
background: rgb(255 255 255 / 50%);
}
}
}
}
@keyframes meteor-fall {
0% {
opacity: 1;
transform: translate(0, -60px) rotate(-45deg);
}
100% {
opacity: 0;
transform: translate(400px, 340px) rotate(-45deg);
}
}
@media (width <= 640px) {
.basic-banner {
box-sizing: border-box;
justify-content: flex-start;
padding: 16px;
&__title {
font-size: 1.4rem;
}
&__background-image {
display: none;
}
&.has-decoration::after {
display: none;
}
}
}
</style>
@@ -0,0 +1,114 @@
<!-- 卡片横幅组件 -->
<template>
<div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }">
<div class="flex-c flex-col gap-4 text-center">
<div class="w-45">
<img :src="image" :alt="title" class="w-full h-full object-contain" />
</div>
<div class="box-border px-4">
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
<p class="m-0 text-sm text-g-600">{{ description }}</p>
</div>
<div class="flex-c gap-3">
<div
v-if="cancelButton?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
:style="{
backgroundColor: cancelButton?.color,
color: cancelButton?.textColor
}"
@click="handleCancel"
>
{{ cancelButton?.text }}
</div>
<div
v-if="button?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
:style="{ backgroundColor: button?.color, color: button?.textColor }"
@click="handleClick"
>
{{ button?.text }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// 导入默认图标
import defaultIcon from '@imgs/3d/icon1.webp'
defineOptions({ name: 'ArtCardBanner' })
// 定义卡片横幅组件的属性接口
interface CardBannerProps {
/** 高度 */
height?: string
/** 图片路径 */
image?: string
/** 标题文本 */
title: string
/** 描述文本 */
description: string
/** 主按钮配置 */
button?: {
/** 是否显示 */
show?: boolean
/** 按钮文本 */
text?: string
/** 背景颜色 */
color?: string
/** 文字颜色 */
textColor?: string
}
/** 取消按钮配置 */
cancelButton?: {
/** 是否显示 */
show?: boolean
/** 按钮文本 */
text?: string
/** 背景颜色 */
color?: string
/** 文字颜色 */
textColor?: string
}
}
// 定义组件属性默认值
withDefaults(defineProps<CardBannerProps>(), {
height: '24rem',
image: defaultIcon,
title: '',
description: '',
// 主按钮默认配置
button: () => ({
show: true,
text: '查看详情',
color: 'var(--theme-color)',
textColor: '#fff'
}),
// 取消按钮默认配置
cancelButton: () => ({
show: false,
text: '取消',
color: '#f5f5f5',
textColor: '#666'
})
})
// 定义组件事件
const emit = defineEmits<{
(e: 'click'): void // 主按钮点击事件
(e: 'cancel'): void // 取消按钮点击事件
}>()
// 主按钮点击处理函数
const handleClick = () => {
emit('click')
}
// 取消按钮点击处理函数
const handleCancel = () => {
emit('cancel')
}
</script>
@@ -0,0 +1,40 @@
<!-- 返回顶部按钮 -->
<template>
<Transition
enter-active-class="tad-300 ease-out"
leave-active-class="tad-200 ease-in"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div
v-show="showButton"
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
@click="scrollToTop"
>
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
</div>
</Transition>
</template>
<script setup lang="ts">
import { useCommon } from '@/hooks/core/useCommon'
defineOptions({ name: 'ArtBackToTop' })
const { scrollToTop } = useCommon()
const showButton = ref(false)
const scrollThreshold = 300
onMounted(() => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
const { y } = useScroll(scrollContainer)
watch(y, (newY: number) => {
showButton.value = newY > scrollThreshold
})
}
})
</script>
@@ -0,0 +1,21 @@
<!-- 系统logo -->
<template>
<div class="flex-cc">
<img :style="logoStyle" src="@imgs/common/logo.png" alt="logo" class="h-full" />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtLogo' })
interface Props {
/** logo 大小(高度,px;K 标为 1.38:1 横向图形,宽度自适应) */
size?: number | string
}
const props = withDefaults(defineProps<Props>(), {
size: 36
})
const logoStyle = computed(() => ({ height: `${props.size}px` }))
</script>
@@ -0,0 +1,24 @@
<!-- 图标组件 -->
<template>
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" />
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false })
interface Props {
/** Iconify icon name */
icon?: string
}
defineProps<Props>()
const attrs = useAttrs()
const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || '',
style: (attrs.style as string) || ''
}))
</script>
@@ -0,0 +1,103 @@
<!-- 柱状图卡片 -->
<template>
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
<div class="mb-5 flex-b items-start px-5 pt-5">
<div>
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
{{ value }}
</p>
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
</div>
<div
class="text-sm font-medium text-danger"
:class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']"
>
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
</div>
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
{{ date }}
</div>
</div>
<div
ref="chartRef"
class="absolute bottom-0 left-0 right-0 mx-auto"
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
></div>
</div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import { type EChartsOption } from '@/plugins/echarts'
defineOptions({ name: 'ArtBarChartCard' })
interface Props {
/** 数值 */
value: number
/** 标签 */
label: string
/** 百分比 +(绿色)-(红色) */
percentage: number
/** 日期 */
date?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 图表数据 */
chartData: number[]
/** 柱状图宽度 */
barWidth?: string
/** 是否为迷你图表 */
isMiniChart?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 11,
barWidth: '26%'
})
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
},
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
watchSources: [() => props.chartData, () => props.color, () => props.barWidth],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor
return {
grid: {
top: 0,
right: 0,
bottom: 15,
left: 0
},
xAxis: {
type: 'category',
show: false
},
yAxis: {
type: 'value',
show: false
},
series: [
{
data: props.chartData,
type: 'bar',
barWidth: props.barWidth,
itemStyle: {
color: computedColor,
borderRadius: 2
}
}
]
}
}
})
</script>
@@ -0,0 +1,74 @@
<!-- 数据列表卡片 -->
<template>
<div class="art-card p-5">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<div v-for="(item, index) in list" :key="index" class="flex-c py-3">
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
<ArtSvgIcon :icon="item.icon" class="text-xl" />
</div>
<div class="flex-1">
<div class="mb-1 text-sm">{{ item.title }}</div>
<div class="text-xs text-g-500">{{ item.status }}</div>
</div>
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
</div>
</ElScrollbar>
<ElButton
class="mt-[25px] w-full text-center"
v-if="showMoreButton"
v-ripple
@click="handleMore"
>查看更多</ElButton
>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtDataListCard' })
interface Props {
/** 数据列表 */
list: Activity[]
/** 标题 */
title: string
/** 副标题 */
subtitle?: string
/** 最大显示数量 */
maxCount?: number
/** 是否显示更多按钮 */
showMoreButton?: boolean
}
interface Activity {
/** 标题 */
title: string
/** 状态 */
status: string
/** 时间 */
time: string
/** 样式类名 */
class: string
/** 图标 */
icon: string
}
const ITEM_HEIGHT = 66
const DEFAULT_MAX_COUNT = 5
const props = withDefaults(defineProps<Props>(), {
maxCount: DEFAULT_MAX_COUNT
})
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
const emit = defineEmits<{
/** 点击更多按钮事件 */
(e: 'more'): void
}>()
const handleMore = () => emit('more')
</script>
@@ -0,0 +1,124 @@
<!-- 环型图卡片 -->
<template>
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
<div class="flex box-border h-full p-5 pr-2">
<div class="flex w-full items-start gap-5">
<div class="flex-b h-full flex-1 flex-col">
<p class="m-0 text-xl font-medium leading-tight text-g-900">
{{ title }}
</p>
<div>
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
{{ formatNumber(value) }}
</p>
<div
class="mt-1.5 text-xs font-medium"
:class="percentage > 0 ? 'text-success' : 'text-danger'"
>
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
<span v-if="percentageLabel">{{ percentageLabel }}</span>
</div>
</div>
<div class="mt-2 flex gap-4 text-xs text-g-600">
<div v-if="currentValue" class="flex-cc">
<div class="size-2 bg-theme/100 rounded mr-2"></div>
{{ currentValue }}
</div>
<div v-if="previousValue" class="flex-cc">
<div class="size-2 bg-g-400 rounded mr-2"></div>
{{ previousValue }}
</div>
</div>
</div>
<div class="flex-c h-full max-w-40 flex-1">
<div ref="chartRef" class="h-30 w-full"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { type EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
defineOptions({ name: 'ArtDonutChartCard' })
interface Props {
/** 数值 */
value: number
/** 标题 */
title: string
/** 百分比 */
percentage: number
/** 百分比标签 */
percentageLabel?: string
/** 当前年份 */
currentValue?: string
/** 去年年份 */
previousValue?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 半径 */
radius?: [string, string]
/** 数据 */
data: [number, number]
}
const props = withDefaults(defineProps<Props>(), {
height: 9,
radius: () => ['70%', '90%'],
data: () => [0, 0]
})
const formatNumber = (num: number) => {
return num.toLocaleString()
}
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: props.data.every((val) => val === 0)
},
checkEmpty: () => props.data.every((val) => val === 0),
watchSources: [
() => props.data,
() => props.color,
() => props.radius,
() => props.currentValue,
() => props.previousValue
],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor
return {
series: [
{
type: 'pie',
radius: props.radius,
avoidLabelOverlap: false,
label: {
show: false
},
data: [
{
value: props.data[0],
name: props.currentValue,
itemStyle: { color: computedColor }
},
{
value: props.data[1],
name: props.previousValue,
itemStyle: { color: '#e6e8f7' }
}
]
}
]
}
}
})
</script>
@@ -0,0 +1,89 @@
<!-- 图片卡片 -->
<template>
<div class="w-full c-p" @click="handleClick">
<div class="art-card overflow-hidden">
<div class="relative w-full aspect-[16/10] overflow-hidden">
<ElImage
:src="props.imageUrl"
fit="cover"
loading="lazy"
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
>
<template #placeholder>
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
<ElIcon><Picture /></ElIcon>
</div>
</template>
</ElImage>
<div
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
v-if="props.readTime"
>
{{ props.readTime }} 阅读
</div>
</div>
<div class="p-4">
<div
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
v-if="props.category"
>
{{ props.category }}
</div>
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
<div class="flex-c gap-4 text-xs text-g-600">
<span class="flex-c gap-1" v-if="props.views">
<ElIcon class="text-base"><View /></ElIcon>
{{ props.views }}
</span>
<span class="flex-c gap-1" v-if="props.comments">
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
{{ props.comments }}
</span>
<span>{{ props.date }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Picture, View, ChatLineRound } from '@element-plus/icons-vue'
defineOptions({ name: 'ArtImageCard' })
interface Props {
/** 图片地址 */
imageUrl: string
/** 标题 */
title: string
/** 分类 */
category?: string
/** 阅读时间 */
readTime?: string
/** 浏览量 */
views?: number
/** 评论数 */
comments?: number
/** 日期 */
date?: string
}
const props = withDefaults(defineProps<Props>(), {
imageUrl: '',
title: '',
category: '',
readTime: '',
views: 0,
comments: 0,
date: ''
})
const emit = defineEmits<{
(e: 'click', card: Props): void
}>()
const handleClick = () => {
emit('click', props)
}
</script>
@@ -0,0 +1,126 @@
<!-- 折线图卡片 -->
<template>
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
<div class="mb-2.5 flex-b items-start p-5">
<div>
<p class="text-2xl font-medium leading-none">
{{ value }}
</p>
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
</div>
<div
class="text-sm font-medium"
:class="[
percentage > 0 ? 'text-success' : 'text-danger',
isMiniChart ? 'absolute bottom-5' : ''
]"
>
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
</div>
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
{{ date }}
</div>
</div>
<div
ref="chartRef"
class="absolute bottom-0 left-0 right-0 box-border w-full"
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
></div>
</div>
</template>
<script setup lang="ts">
import { graphic, type EChartsOption } from '@/plugins/echarts'
import { getCssVar, hexToRgba } from '@/utils/ui'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
defineOptions({ name: 'ArtLineChartCard' })
interface Props {
/** 数值 */
value: number
/** 标签 */
label: string
/** 百分比 */
percentage: number
/** 日期 */
date?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 是否显示区域颜色 */
showAreaColor?: boolean
/** 图表数据 */
chartData: number[]
/** 是否为迷你图表 */
isMiniChart?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 11
})
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
},
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor
return {
grid: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
xAxis: {
type: 'category',
show: false,
boundaryGap: false
},
yAxis: {
type: 'value',
show: false
},
series: [
{
data: props.chartData,
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: {
width: 3,
color: computedColor
},
areaStyle: props.showAreaColor
? {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: props.color
? hexToRgba(props.color, 0.2).rgba
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
},
{
offset: 1,
color: props.color
? hexToRgba(props.color, 0.01).rgba
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
}
])
}
: undefined
}
]
}
}
})
</script>
@@ -0,0 +1,86 @@
<!-- 进度条卡片 -->
<template>
<div class="art-card h-32 flex flex-col justify-center px-5">
<div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
</div>
<div>
<ArtCountTo
class="mb-1 block text-2xl font-semibold"
:target="percentage"
:duration="2000"
suffix="%"
:style="{ textAlign: icon ? 'right' : 'left' }"
/>
<p class="text-sm text-g-500">{{ title }}</p>
</div>
</div>
<ElProgress
:percentage="currentPercentage"
:stroke-width="strokeWidth"
:show-text="false"
:color="color"
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtProgressCard' })
interface Props {
/** 进度百分比 */
percentage: number
/** 标题 */
title: string
/** 颜色 */
color?: string
/** 图标 */
icon?: string
/** 图标样式 */
iconStyle?: string
/** 进度条宽度 */
strokeWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
strokeWidth: 5,
color: '#67C23A'
})
const animationDuration = 500
const currentPercentage = ref(0)
const animateProgress = () => {
const startTime = Date.now()
const startValue = currentPercentage.value
const endValue = props.percentage
const animate = () => {
const currentTime = Date.now()
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / animationDuration, 1)
currentPercentage.value = startValue + (endValue - startValue) * progress
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}
onMounted(() => {
animateProgress()
})
// 当 percentage 属性变化时重新执行动画
watch(
() => props.percentage,
() => {
animateProgress()
}
)
</script>

Some files were not shown because too many files have changed in this diff Show More