Compare commits

...

3 Commits

19 changed files with 888 additions and 432 deletions

View File

@@ -16,7 +16,7 @@ Token = ""
# =========================
[Http]
# HTTP服务监听端口
Port = 8080
Port = 18080
# 监听地址(可选,默认 0.0.0.0
# Host = "0.0.0.0"

View File

@@ -1,144 +1,155 @@
# Portal 全局设计准则 (Global Design Guidelines)
# Portal 适老化 UI/UX 与配色落地规范v1
> **适用范围**: PC 端 Portal 站点
> **核心风格**: 内容型(浅灰背景 + 白色卡片),强调信息层级与阅读体验。
> 适用范围`frontend/portal`PC 优先)
>
> 目标在保持简约风格的前提下提升中老年用户50+)的可读性、可操作性与安全感。
## 1. 全局布局 (Layout & Container)
## 1. 设计目标与原则
采用经典的垂直分布布局,确保内容区域聚焦且具备良好的扩展性。
### 1.1 设计目标
### 1.1 页面结构 (DOM Structure)
- **Root**: `min-h-screen flex flex-col bg-slate-50` (浅灰背景hex: `#F8FAFC`)
- **Header**: `fixed top-0 w-full z-50` (固定顶部)
- **Main**: `flex-grow pt-16` (内容自适应填充,顶部留出 Header 高度)
- **Footer**: `mt-auto` (页脚沉底)
1. **简约**:减少视觉噪音与无关装饰,突出主任务路径。
2. **适老**:强化文字对比、按钮可见性、操作反馈。
3. **可信**:降低“误触焦虑”,让界面更稳重、更可预期。
### 1.2 核心容器 (Main Container)
所有页面核心内容需包裹在标准容器内,以保证视觉统一。
- **宽度限制**: `max-w-screen-xl` (Tailwind default: 1280px)
- **对齐方式**: `mx-auto` (水平居中)
- **内边距**: `px-4 sm:px-6 lg:px-8` (响应式呼吸感)
- **示例代码**:
```html
<main class="flex-grow pt-16">
<div class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Content Here -->
</div>
</main>
```
### 1.2 设计原则
### 1.3 基础色彩规范 (Basic Colors)
- **背景色**: `bg-slate-50` (页面底色)
- **卡片色**: `bg-white` (内容承载)
- **边框色**: `border-slate-200` (轻微分割线)
- **文字色**:
- 主要: `text-slate-900` (标题、正文)
- 次要: `text-slate-500` (描述、辅助信息)
- 链接: `text-primary-600 hover:text-primary-700`
1. **一屏一主动作**:每屏突出 1 个主任务(查、填、确认)。
2. **显性导航**:始终明确当前所在位置与可返回路径。
3. **高对比优先**:关键文字和交互区优先保障可读性。
4. **状态不靠颜色单独表达**:需配合图标与文案。
5. **可切换高对比模式**:满足低视力/高敏感场景。
---
## 2. 顶部导航栏 (TopNavbar)
## 2. 颜色系统Design Tokens
全局常驻入口,承载品牌认知与核心路径导航。
实际实现文件:`frontend/portal/src/assets/main.css`
### 2.1 外观 (Appearance)
- **尺寸**: 高度 `h-16` (64px)
- **背景**: `bg-white` (纯白背景)
- **质感**: `border-b border-slate-200` 或 `shadow-sm` (轻微投影,提升层级)
### 2.1 语义 Token默认主题
### 2.2 布局 (Flex Grid)
`flex items-center justify-between` + `Container`
- `--color-bg-base`:页面底色
- `--color-bg-surface`:卡片/容器底色
- `--color-bg-surface-highlight`:弱强调底色
- `--color-text-main`:主文字
- `--color-text-muted`:次文字
- `--color-text-inverted`:反白文字
- `--color-border-base`:默认边框
- `--color-border-highlight`:强调边框
- `--color-primary-50...950`:品牌主色梯度
| 区域 | 元素 | 交互/样式 |
| --- | --- | --- |
| **Left** | **Logo** | 图片/SVG + 文字,点击回首页 (`/`)。高度控制在 32-40px。 |
| **Center-Left** | **Nav Links** | 首页、分类、标签等。间距 `space-x-8`。<br>Default: `text-slate-600 font-medium`<br>Hover: `text-primary-600`<br>Active: `text-primary-600` |
| **Center-Right** | **Global Search** | 圆角矩形输入框 `rounded-full` 或 `rounded-lg`。<br>`bg-slate-100 focus:bg-white focus:ring`。<br>宽度:默认 `w-64`,聚焦时可伸展。 |
| **Right** | **User Actions** | **未登录**: <br>- [登录]: Ghost Button (`text-slate-600 hover:bg-slate-50`)<br>- [注册]: Primary Button (`bg-primary-600 text-white`)<br>**已登录**: <br>- [通知]: **Bell Icon** (h-10 w-10 flex items-center justify-center rounded-full hover:bg-slate-100 relative)。<br> - **Badge**: 右上角红色圆点或数字,表示未读。<br> - **交互**: 点击直接跳转至 `/me/notifications`。<br>- [头像]: Avatar + Dropdown Menu |
### 2.2 高对比主题
切换选择器:`[data-theme='senior-high-contrast']`
高对比主题目标:
- 背景更纯净(白底)
- 文本更深(黑字)
- 重点更醒目(黄色强调)
- 边框更明确(黑色边框)
### 2.3 PrimeVue 语义映射
已通过全局变量映射统一第三方组件视觉:
- `--p-content-background`
- `--p-content-color`
- `--p-content-border-color`
- `--p-primary-color`
- `--p-primary-contrast-color`
- `--p-inputtext-background`
- `--p-inputtext-color`
- `--p-inputtext-border-color`
---
## 3. 页脚 (Footer)
## 3. 组件与页面层应用规范
全局底部信息区,采用深色风格以稳定视觉重心。
### 3.1 全局布局(已落地)
### 3.1 外观 (Appearance)
- **背景**: `bg-slate-900` (深色)
- **文字**: `text-slate-400` (灰色文本,避免纯白刺眼)
- **链接**: Hover 时变亮 `hover:text-white`
- 页面容器:`bg-base text-content`
- 卡片容器:`bg-surface border border-line`
- 弱强调区:`bg-surface-highlight`
### 3.2 内容结构
- **Upper Section (Links)**: `py-12`
- Grid 布局 (3-4 列)
- 栏目:关于我们、帮助中心、法律条款、关注我们 (Social Icons)
- **Bottom Section (Copyright)**: `border-t border-slate-800 py-6`
- 版权声明 (© 2025 Quyun)
- ICP 备案号 / 公安网备
### 3.2 导航栏TopNavbar
- 背景:`bg-surface`
- 边框:`border-line`
- 主文案:`text-content`
- 次级文案/图标:`text-muted`
- 焦点反馈:`focus:ring` + 高对比 outline
### 3.3 页脚AppFooter
- 背景:`bg-surface-highlight`
- 文案:`text-muted`
- 标题:`text-content`
- 交互 hover`hover:text-primary-600`
### 3.4 用户/创作者侧边栏
- 容器:`bg-surface border border-line`
- 非激活菜单:`text-muted`
- hover`hover:bg-surface-highlight hover:text-content`
- 激活态:`bg-primary-600 text-white`
---
## 4. 通用 UI 元素 (Common Components)
## 4. 交互与文案建议(执行级)
### 4.1 内容卡片 (Content Card)
用于承载列表项、详情块等。
- **样式**: `bg-white rounded-lg border border-slate-100 shadow-sm transition-shadow hover:shadow-md`
- **内边距**: `p-4` 或 `p-6`
### 4.2 按钮 (Buttons)
*针对中老年用户优化:增大点击区域与文字标签*
- **Primary**: `bg-primary-600 text-white hover:bg-primary-700 rounded-lg px-6 py-3 text-lg h-12 flex items-center justify-center`
- **Secondary/Outline**: `border-2 border-slate-300 text-slate-800 hover:bg-slate-50 rounded-lg px-6 py-3 text-lg h-12 flex items-center justify-center`
- **Ghost**: `text-slate-700 hover:bg-slate-100 rounded-lg px-4 py-3 text-lg h-12 flex items-center justify-center`
- **说明**: 按钮高度至少 `48px` (`h-12`),边框适当加粗以便识别。
### 4.3 标题与文字 (Typography)
*针对中老年用户优化:提升基准字号与行高*
- **Root Font Size**: PC 端默认 `16px`,允许用户浏览器缩放。
- **Body Text**: `text-base` (16px) 或 `text-lg` (18px),行高 `leading-relaxed` (1.625)。
- **H1 (页面主标题)**: `text-3xl sm:text-4xl font-bold text-slate-900 mb-6`
- **H2 (区块标题)**: `text-2xl font-bold text-slate-900 mb-5`
- **H3 (小标题)**: `text-xl font-semibold text-slate-900 mb-3`
- **辅助文字**: 避免使用小于 `14px` 的文字。辅助色最低为 `text-slate-600` (保证 4.5:1 对比度)。
### 4.4 输入框 (Inputs)
- **尺寸**: 高度 `h-12` (48px) 或 `h-14` (56px)。
- **样式**: `text-lg px-4 border-slate-300 focus:border-primary-600 focus:ring-2`。
- **Label**: 必须显示 Label字号 `text-base` 或 `text-lg`,不建议仅用 placeholder。
### 4.5 交互反馈 (Interactive Feedback)
- **鼠标手势**: 所有可点击元素(卡片、按钮、链接、自定义交互区)必须明确指定 `cursor: pointer`。
- **视觉反馈**:
- Hover 时应伴随背景色微调、阴影加深或缩放效果 (`transition-all`)。
- Active 时应有轻微的按下缩放效果 (`active:scale-[0.98]`),增强操作确认感。
1. 关键按钮文案使用动词开头:如“确认支付”“保存设置”。
2. 失败文案避免术语:
- ❌ 参数错误
- ✅ 信息未填写完整,请检查后重试
3. 重要状态展示采用“图标 + 颜色 + 文案”三重表达。
4. 表单错误提示紧贴字段,不只在顶部汇总。
---
## 5. 响应式与可访问性 (Responsive & Accessibility)
## 5. 前端落地文件清单(本次)
确保站点在移动端具备良好体验,并符合 WCAG 可访问性标准,**特别关注中老年用户体验**。
- `frontend/portal/src/assets/main.css`
- `frontend/portal/src/main.js`
- `frontend/portal/src/layout/LayoutMain.vue`
- `frontend/portal/src/layout/LayoutUser.vue`
- `frontend/portal/src/layout/LayoutCreator.vue`
- `frontend/portal/src/layout/LayoutAuth.vue`
- `frontend/portal/src/components/TopNavbar.vue`
- `frontend/portal/src/components/AppFooter.vue`
### 5.1 TopNavbar 响应式策略 (Mobile Adaptation)
当视口宽度小于 `md` (768px) 时,导航栏需进行自适应折叠。
---
- **导航链接**: 收纳至左侧或右侧的 **汉堡菜单 (Hamburger Menu)**。
- 菜单图标需显著,加文字标签 "菜单" 辅助识别。
- 抽屉内菜单项字号 `text-lg`,行高宽松。
- **全局搜索**:
- **默认状态**: 仅展示搜索图标 (Magnifying Glass)。
- **交互状态**: 点击图标后,展开全宽搜索栏覆盖 Logo 或 弹出模态搜索框。
- **用户区**:
- 若空间允许,保留头像/登录按钮;若空间不足,并入汉堡菜单底部。
## 6. 验收标准(视觉评审)
### 5.2 键盘导航与焦点 (Keyboard & Focus)
- **Focus Visible**: 所有交互元素(链接、按钮、输入框)在键盘聚焦时必须有清晰的可见轮廓。
- 推荐样式: `focus-visible:ring-4 focus-visible:ring-primary-400 focus-visible:outline-none` (加宽 Ring 宽度)。
- **Skip Link**: 页面首个可聚焦元素应为 "跳转至主要内容 (Skip to main content)"。
### 6.1 基础检查
### 5.3 触控目标 (Touch Targets)
- **最小点击面积**: 移动端交互元素的可点击区域必须 $\ge 48 \times 48$ px (Tailwind `min-h-[48px] min-w-[48px]`)
- 按钮间距 `gap-4` 以上,防止误触
1. 首页、登录页、用户中心、创作者中心色彩风格一致。
2. 主操作按钮在 3 秒内可被识别
3. 次级文字在普通显示器下可清晰阅读
### 5.4 ARIA 与语义化 (Semantics)
- **表单标签**: 所有 `<input>` 必须有对应的可视 `<label>`。
- **高对比度**: 文本与背景对比度至少 **4.5:1** (WCAG AA),关键信息追求 **7:1** (WCAG AAA)。避免使用浅灰文字
### 6.2 无障碍检查
1. 高对比模式可切换并立即生效
2. 聚焦态可视(键盘 Tab 导航清晰)。
3. 状态提示不依赖单一颜色表达。
### 6.3 快速切换方式(调试)
```js
document.documentElement.setAttribute('data-theme', 'senior-high-contrast')
```
恢复默认:
```js
document.documentElement.removeAttribute('data-theme')
```
---
## 7. 后续优化建议v2
1. 将高对比模式入口显式放到导航栏设置中(非仅调试)。
2. 增加“标准/适老”主题切换并持久化到本地。
3. 做 55+ 用户可用性走查(任务完成率、误触率、求助率)。
4. 对支付/实名认证等高风险流程增加更明确的安全确认文案。

View File

@@ -1,115 +0,0 @@
# Implementation Plan: full-lint-remediation
**Branch**: `[chore/full-lint-remediation]` | **Date**: 2026-02-05 | **Spec**: `N/A`
**Input**: Full repo lint remediation covering backend and frontend lint/build steps.
## Summary
Remediate all existing lint failures across the backend and frontend by systematically addressing security warnings, de-duplication, complexity, formatting, naming/style violations, and frontend lint/build issues, while preserving behavior and following project constraints.
## Technical Context
**Language/Version**: Go 1.x
**Primary Dependencies**: Fiber, GORM-Gen, River, golangci-lint
**Storage**: PostgreSQL
**Testing**: `make lint` in `backend/`, `go test ./...`, `npm -C frontend/superadmin run lint`, `npm -C frontend/superadmin run build`, `npm -C frontend/portal run lint`, `npm -C frontend/portal run build`
**Target Platform**: Linux server
**Project Type**: Web application (backend + frontend)
**Performance Goals**: N/A
**Constraints**: Follow `backend/llm.txt`; do not edit generated files; avoid behavior changes while refactoring
**Scale/Scope**: Backend lint errors plus frontend lint/build issues in portal/superadmin
## Constitution Check
- Follow `backend/llm.txt` (controller thin, services handle DB, Chinese comments for business logic).
- Do not edit generated files (`*.gen.go`, `backend/docs/docs.go`).
- Fix lint issues without behavior changes or API surface drift.
## Project Structure
### Documentation (this feature)
```text
docs/
└── plan.md
```
### Source Code (repository root)
```text
backend/
├── app/services/super.go
├── app/services/creator_report.go
├── app/services/content.go
├── app/services/creator.go
├── app/services/coupon.go
├── app/services/common.go
├── app/commands/seed/seed.go
├── app/commands/storage_migrate/migrate.go
├── app/jobs/media_process_job.go
├── providers/http/swagger/config.go
├── providers/http/swagger/template.go
├── providers/http/engine.go
├── providers/jwt/jwt.go
├── providers/postgres/config.go
└── providers/postgres/postgres.go
frontend/
├── superadmin/
│ ├── src/
│ └── package.json
└── portal/
├── src/
└── package.json
```
**Structure Decision**: Web application; full repo lint remediation (backend + frontend).
## Plan Phases
1. **Security & correctness**: Address gosec issues (weak crypto, weak random, unsafe conversions) and errcheck/errorlint/wrapcheck failures.
2. **De-duplication & complexity**: Reduce dupl/gocognit/gocyclo/funlen by extracting helpers and simplifying large service methods (especially `services/super.go`).
3. **Style & formatting**: Resolve revive naming issues, line-length (lll), prealloc, nilerr, and other style violations.
4. **Frontend lint/build**: Resolve frontend lint/build issues for portal/superadmin.
5. **Verification**: Run backend and frontend lint/build/test commands until clean.
## Tasks
1. Capture baseline lint outputs (save `cd backend && make lint` output; run `npm -C frontend/superadmin run lint` / `npm -C frontend/portal run lint`) and group errors by category/file; establish remediation order (security → complexity → style).
2. Fix gosec issues: choose between (a) keep MD5 for non-security hashing with explicit `//nolint:gosec` justification, or (b) migrate to SHA-256 with any required backfill; switch weak random to crypto/rand where required; guard integer conversions.
3. Fix errcheck/errorlint/wrapcheck issues in providers and error handling.
4. Remove duplicated blocks (dupl) by extracting shared helper functions in `services/super.go` and `services/creator_report.go`.
5. Reduce high cognitive/cyclomatic complexity by helper extraction only; keep inputs/outputs and query semantics unchanged.
6. Address revive naming and lll formatting (split long lines, rename variables/types as needed).
7. Run backend verification (`cd backend && make lint`, `go test ./...`).
8. Run frontend lint/build (`npm -C frontend/superadmin run lint`, `npm -C frontend/superadmin run build`, `npm -C frontend/portal run lint`, `npm -C frontend/portal run build`). Review ESLint `--fix` diffs carefully.
9. Re-run all lint/build/test commands until clean.
## Dependencies
- Security fixes precede refactors to ensure safe baselines.
- De-duplication/complexity refactors should precede style fixes to avoid rework.
- Backend verification depends on remediation tasks; frontend verification depends on frontend lint/build tasks.
## Acceptance Criteria
- Backend lint passes with no errors (`cd backend && make lint`).
- Frontend lint/build passes (`npm -C frontend/superadmin run lint`, `npm -C frontend/superadmin run build`, `npm -C frontend/portal run lint`, `npm -C frontend/portal run build`).
- `go test ./...` passes (or failures are documented as pre-existing and approved).
- No generated files modified manually.
- No functional/API behavior changes observed during lint fixes.
## Risks
- Large refactors in `services/super.go` may inadvertently change behavior; must keep refactors minimal and covered by tests.
- Security fixes may require signature changes (e.g., hash algorithm changes); need careful review for backward compatibility.
- Volume of lint violations may require staged remediation; ensure each stage keeps lint green where possible.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| N/A | N/A | N/A |

111
docs/plans/2026-02-06.md Normal file
View File

@@ -0,0 +1,111 @@
# Implementation Plan: portal-tenant-home-route-query-sync
**Branch**: `[main]` | **Date**: 2026-02-06 | **Spec**: `N/A`
**Input**: User confirmed next step should be “connect route first, then implement query-sync” for `frontend/portal/src/views/tenant/HomeView.vue`.
## Summary
Expose `tenant/HomeView.vue` through an active router path, then implement full `genre` query synchronization for its topic filter so navigation into this page with `?genre=...` produces consistent filtering behavior (and optional URL state sync when in-page filters change), while preserving existing content loading and UX.
## Technical Context
**Language/Version**: Vue 3 + JavaScript (Vite)
**Primary Dependencies**: Vue Router, PrimeVue, TailwindCSS
**Storage**: N/A (frontend behavior/routing only)
**Testing**: `npm -C frontend/portal run lint`, `npm -C frontend/portal run build`, browser flow checks for tenant page + query sync; `go test ./...` for frontend-involved phase completion governance
**Target Platform**: Web browser (`frontend/portal`)
**Project Type**: Web frontend module
**Performance Goals**: No notable regression in tenant page load/filter response
**Constraints**: Minimal incremental changes; no backend API contract changes; keep existing page styles/layout; avoid generated-file edits
**Scale/Scope**: `frontend/portal/src/router/index.js`, `frontend/portal/src/views/tenant/HomeView.vue` (optional minimal caller path adjustment if needed)
## Constitution Check
- Plan-first requirement satisfied before non-trivial implementation.
- Scope constrained to route exposure + query-sync behavior for tenant page.
- No generated files involved.
- Acceptance includes frontend page-flow validation and backend `go test ./...` evidence per repository rules.
## Project Structure
### Documentation (this feature)
```text
docs/
└── plan.md
```
### Source Code (repository root)
```text
frontend/portal/
├── src/router/index.js
├── src/views/tenant/HomeView.vue
└── src/views/HomeView.vue (optional tiny entry-path adjustment only if needed)
```
**Structure Decision**: Activate existing `tenant/HomeView.vue` via router first, then add robust route-query synchronization inside that page to avoid broad refactor.
## Plan Phases
1. **Phase 1 — Route Activation**
- Add explicit route entry for tenant profile page to render `views/tenant/HomeView.vue`.
- Ensure route path does not conflict with existing tenant homepage / creator routes.
2. **Phase 2 — Topic/Query Synchronization**
- Parse incoming `route.query.genre` and initialize selected topic filter.
- Watch query changes to keep selected topic in sync.
- Optionally sync selected topic back to URL query (replace) for deep-link consistency.
- Keep sort/topic/search/loadMore behavior consistent with existing logic.
3. **Phase 3 — Entry Path and Validation**
- Ensure there is at least one practical navigation path to this newly connected route.
- Run diagnostics and command checks.
- Verify browser flows for route rendering and query-driven filtering.
## Tasks
1. Add tenant profile route in `router/index.js` to load `views/tenant/HomeView.vue`.
2. In `tenant/HomeView.vue`, add helper to normalize `route.query.genre` and initialize `selectedTopic` from it.
3. Add watcher for route query changes (`genre`) to update in-page filter state.
4. Add safe guard/initialization flag to avoid duplicate or race-condition fetches.
5. Ensure existing data fetch (`query.genre`) continues to rely on selected topic string.
6. (If needed) Add minimal navigation entry in existing pages so new route is reachable through UI.
7. Run `lsp_diagnostics` on changed files.
8. Run `npm -C frontend/portal run lint`.
9. Run `npm -C frontend/portal run build`.
10. Run backend `go test ./...`.
11. Execute browser verification for route access and `genre` prefilter behavior.
## Dependencies
- Phase 2 depends on Phase 1 route activation.
- Phase 3 depends on completed route/query behavior.
- Browser acceptance depends on build/runtime availability.
## Acceptance Criteria
- `tenant/HomeView.vue` is reachable via a real router path (not dead code).
- Entering tenant page with `?genre=xxx` preselects topic filter and requests content with matching `genre` query.
- Query updates (navigation changes) keep topic filter state synchronized.
- Existing tenant page sort/topic/search/loadMore behaviors remain functional.
- No new 404 regressions introduced by route activation.
- `lsp_diagnostics` clean on changed files (or only known template-analysis false positives reported).
- `npm -C frontend/portal run lint` passes.
- `npm -C frontend/portal run build` passes.
- backend `go test ./...` passes (or unrelated pre-existing failures explicitly reported).
- Browser page-flow checks pass for route + query-sync scenarios.
## Risks
- Newly connected route may require clearer entry path from existing UI to be practically discoverable.
- Query-sync watchers can accidentally trigger duplicate fetches without initialization guards.
- Topic list values and query genre may not always align exactly, requiring graceful fallback behavior.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| N/A | N/A | N/A |

View File

@@ -2,6 +2,7 @@
@plugin "tailwindcss-animate";
:root {
/* Existing Primary Scale (Preserved) */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
@@ -13,6 +14,57 @@
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
--color-primary-950: #172554;
/* Senior-Friendly Semantic Base (Light Mode) */
/* Warm, low-glare backgrounds */
--color-bg-base: #f9fafb; /* gray-50 equivalent but semantic */
--color-bg-surface: #ffffff;
--color-bg-surface-highlight: #f3f4f6; /* gray-100 */
/* High contrast text */
--color-text-main: #0f172a; /* slate-900 */
--color-text-muted: #334155; /* slate-700 - Darker than typical muted for readability */
--color-text-inverted: #ffffff;
/* Borders */
--color-border-base: #cbd5e1; /* slate-300 - Higher contrast border */
--color-border-highlight: #94a3b8; /* slate-400 */
/* Status */
--color-status-success: #166534; /* green-800 for text readability */
--color-status-danger: #991b1b; /* red-800 */
/* PrimeVue Global Semantic Map */
--p-content-background: var(--color-bg-surface);
--p-content-color: var(--color-text-main);
--p-content-border-color: var(--color-border-base);
--p-primary-color: var(--color-primary-600);
--p-primary-contrast-color: #ffffff;
/* PrimeVue Form/Input adjustments */
--p-inputtext-background: var(--color-bg-surface);
--p-inputtext-color: var(--color-text-main);
--p-inputtext-border-color: var(--color-border-base);
}
/* High Contrast Mode for Seniors */
[data-theme='senior-high-contrast'] {
--color-bg-base: #ffffff;
--color-bg-surface: #ffffff;
--color-bg-surface-highlight: #ffff00; /* Yellow highlight for emphasis */
--color-text-main: #000000;
--color-text-muted: #000000;
--color-text-inverted: #ffff00; /* Yellow text on dark backgrounds */
--color-border-base: #000000;
--color-border-highlight: #000000;
--color-primary-500: #0000ee; /* Standard accessible link blue */
--color-primary-600: #0000ee;
/* Force borders to be visible */
--border-width-base: 2px;
}
@theme {
@@ -27,4 +79,37 @@
--color-primary-800: var(--color-primary-800);
--color-primary-900: var(--color-primary-900);
--color-primary-950: var(--color-primary-950);
/* Semantic Map */
--color-base: var(--color-bg-base);
--color-surface: var(--color-bg-surface);
--color-surface-highlight: var(--color-bg-surface-highlight);
--color-content: var(--color-text-main);
--color-muted: var(--color-text-muted);
--color-inverted: var(--color-text-inverted);
--color-line: var(--color-border-base);
--color-line-highlight: var(--color-border-highlight);
}
/* Global Senior Base Styles */
body {
background-color: var(--color-bg-base);
color: var(--color-text-main);
font-size: 18px; /* Slightly larger base font size */
line-height: 1.6;
}
/* Improve focus visibility */
:focus-visible {
outline: 3px solid var(--color-primary-600);
outline-offset: 2px;
}
a {
text-decoration-thickness: 1px;
}
a:hover {
text-decoration: underline;
}

View File

@@ -1,16 +1,16 @@
<template>
<footer class="bg-slate-900 text-slate-400 mt-auto">
<footer class="bg-surface-highlight text-muted mt-auto border-t border-line">
<div class="mx-auto max-w-screen-xl py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1">
<div class="flex items-center gap-2 mb-4">
<div
class="w-8 h-8 bg-white/10 rounded-lg flex items-center justify-center text-white font-bold text-xl"
class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center text-white font-bold text-xl"
>
Q
</div>
<span class="text-xl font-bold text-white">Quyun</span>
<span class="text-xl font-bold text-content">Quyun</span>
</div>
<p class="text-sm leading-relaxed mb-6">
专业的租户管理与内容交付平台连接创作者与用户探索内容的无限可能
@@ -18,17 +18,17 @@
<div class="flex gap-4">
<a
href="#"
class="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center hover:bg-white/20 hover:text-white transition-all"
class="w-8 h-8 rounded-full bg-surface border border-line flex items-center justify-center hover:bg-primary-50 hover:text-primary-600 transition-all"
><i class="pi pi-twitter"></i
></a>
<a
href="#"
class="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center hover:bg-white/20 hover:text-white transition-all"
class="w-8 h-8 rounded-full bg-surface border border-line flex items-center justify-center hover:bg-primary-50 hover:text-primary-600 transition-all"
><i class="pi pi-github"></i
></a>
<a
href="#"
class="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center hover:bg-white/20 hover:text-white transition-all"
class="w-8 h-8 rounded-full bg-surface border border-line flex items-center justify-center hover:bg-primary-50 hover:text-primary-600 transition-all"
><i class="pi pi-discord"></i
></a>
</div>
@@ -36,25 +36,25 @@
<!-- Links -->
<div>
<h3 class="text-white font-bold mb-4">关于我们</h3>
<h3 class="text-content font-bold mb-4">关于我们</h3>
<ul class="space-y-3 text-sm">
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>平台介绍</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>加入我们</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>联系方式</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>合作伙伴</a
>
</li>
@@ -62,25 +62,25 @@
</div>
<div>
<h3 class="text-white font-bold mb-4">帮助中心</h3>
<h3 class="text-content font-bold mb-4">帮助中心</h3>
<ul class="space-y-3 text-sm">
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>用户指南</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>创作者手册</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>常见问题</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>反馈建议</a
>
</li>
@@ -88,25 +88,25 @@
</div>
<div>
<h3 class="text-white font-bold mb-4">法律条款</h3>
<h3 class="text-content font-bold mb-4">法律条款</h3>
<ul class="space-y-3 text-sm">
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>用户协议</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>隐私政策</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>知识产权</a
>
</li>
<li>
<a href="#" class="hover:text-white transition-colors"
<a href="#" class="hover:text-primary-600 transition-colors"
>社区规范</a
>
</li>
@@ -115,7 +115,7 @@
</div>
<div
class="border-t border-slate-800 mt-12 pt-8 flex flex-col md:flex-row justify-between items-center text-xs"
class="border-t border-line mt-12 pt-8 flex flex-col md:flex-row justify-between items-center text-xs"
>
<p>© 2025 Quyun Platform. All rights reserved.</p>
<p class="mt-2 md:mt-0">

View File

@@ -38,7 +38,9 @@ const logout = () => {
</script>
<template>
<nav class="fixed top-0 w-full z-50 bg-white border-b border-slate-200 h-16">
<nav
class="fixed top-0 w-full z-50 bg-surface border-b border-line h-16 text-content"
>
<div
class="mx-auto max-w-screen-xl h-full flex items-center justify-between"
>
@@ -49,7 +51,7 @@ const logout = () => {
>
Q
</div>
<span class="text-xl font-bold text-slate-900 hidden sm:block"
<span class="text-xl font-bold text-content hidden sm:block"
>Quyun</span
>
</router-link>
@@ -58,34 +60,40 @@ const logout = () => {
<div class="hidden md:flex items-center space-x-8">
<router-link
:to="tenantRoute('/')"
class="text-slate-600 font-medium hover:text-primary-600"
class="text-muted font-medium hover:text-primary-600"
active-class="text-primary-600"
>首页</router-link
>
<router-link
:to="tenantRoute('/explore')"
class="text-slate-600 font-medium hover:text-primary-600"
class="text-muted font-medium hover:text-primary-600"
active-class="text-primary-600"
>发现</router-link
>
<router-link
:to="tenantRoute('/topics')"
class="text-slate-600 font-medium hover:text-primary-600"
class="text-muted font-medium hover:text-primary-600"
active-class="text-primary-600"
>专题</router-link
>
<router-link
:to="tenantRoute('/channel')"
class="text-muted font-medium hover:text-primary-600"
active-class="text-primary-600"
>频道</router-link
>
</div>
<!-- Center-Right: Global Search -->
<div class="hidden sm:flex flex-1 max-w-md mx-8">
<div class="relative w-full">
<i
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-muted"
></i>
<input
type="text"
placeholder="搜索感兴趣的内容..."
class="w-full h-10 pl-10 pr-4 rounded-full bg-slate-100 border-none focus:bg-white focus:ring-2 focus:ring-primary-100 text-sm transition-all"
class="w-full h-10 pl-10 pr-4 rounded-full bg-surface-highlight border border-transparent focus:border-primary-300 focus:bg-surface focus:ring-2 focus:ring-primary-100 text-sm transition-all text-content placeholder:text-muted"
/>
</div>
</div>
@@ -96,18 +104,18 @@ const logout = () => {
<!-- Notification -->
<router-link
:to="tenantRoute('/me/notifications')"
class="relative w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-50 text-slate-600"
class="relative w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-highlight text-muted"
>
<i class="pi pi-bell text-xl"></i>
<span
class="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border border-white"
class="absolute top-2 right-2 w-2 h-2 bg-red-600 rounded-full border border-surface"
></span>
</router-link>
<!-- Creator Entry -->
<router-link
:to="tenantRoute('/creator/apply')"
class="hidden sm:flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-50 rounded-lg border border-slate-200"
class="hidden sm:flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-highlight rounded-lg border border-line"
>
<i class="pi pi-pencil"></i>
<span>创作</span>
@@ -116,7 +124,7 @@ const logout = () => {
<!-- Avatar Dropdown -->
<div class="relative group h-full flex items-center">
<button
class="w-9 h-9 rounded-full overflow-hidden border border-slate-200 focus:ring-2 ring-primary-100"
class="w-9 h-9 rounded-full overflow-hidden border border-line focus:ring-2 ring-primary-100"
>
<img
:src="
@@ -132,27 +140,27 @@ const logout = () => {
class="absolute right-0 top-full pt-2 w-48 hidden group-hover:block"
>
<div
class="bg-white rounded-xl shadow-lg border border-slate-100 py-1"
class="bg-surface rounded-xl shadow-lg border border-line py-1"
>
<div class="px-4 py-3 border-b border-slate-50">
<p class="text-sm font-bold text-slate-900">
<div class="px-4 py-3 border-b border-line">
<p class="text-sm font-bold text-content">
{{ user.nickname }}
</p>
<p class="text-xs text-slate-500 truncate">
<p class="text-xs text-muted truncate">
{{ user.phone }}
</p>
</div>
<router-link
:to="tenantRoute('/me')"
class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"
class="block px-4 py-2 text-sm text-content hover:bg-surface-highlight"
>个人中心</router-link
>
<router-link
:to="tenantRoute('/creator')"
class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"
class="block px-4 py-2 text-sm text-content hover:bg-surface-highlight"
>创作者中心</router-link
>
<div class="border-t border-slate-50 mt-1"></div>
<div class="border-t border-line mt-1"></div>
<button
@click="logout"
class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
@@ -174,7 +182,7 @@ const logout = () => {
<!-- Mobile Menu Button -->
<button
class="md:hidden w-10 h-10 flex items-center justify-center text-slate-600"
class="md:hidden w-10 h-10 flex items-center justify-center text-muted"
>
<i class="pi pi-bars text-xl"></i>
</button>

View File

@@ -1,5 +1,7 @@
<template>
<div class="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div
class="min-h-screen bg-base text-content flex items-center justify-center p-4"
>
<router-view />
</div>
</template>

View File

@@ -15,7 +15,7 @@ const isFullWidth = computed(() => {
<template>
<div
class="flex flex-col bg-slate-50"
class="flex flex-col bg-base text-content"
:class="isFullWidth ? 'h-screen overflow-hidden' : 'min-h-screen'"
>
<TopNavbar v-if="!isFullWidth" />
@@ -33,21 +33,21 @@ const isFullWidth = computed(() => {
v-if="!isFullWidth"
>
<div
class="bg-slate-900 rounded-2xl shadow-sm overflow-hidden sticky top-24 text-slate-300 min-h-[600px] flex flex-col"
class="bg-surface rounded-2xl shadow-sm overflow-hidden sticky top-24 text-muted min-h-[600px] flex flex-col border border-line"
>
<!-- Header -->
<div class="p-6 border-b border-slate-800">
<div class="p-6 border-b border-line">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center text-white font-bold text-lg shadow-lg"
class="w-10 h-10 bg-primary-600 rounded-lg flex items-center justify-center text-white font-bold text-lg shadow-lg"
>
<i class="pi pi-palette"></i>
</div>
<div>
<div class="font-bold text-white leading-tight">
<div class="font-bold text-content leading-tight">
创作者中心
</div>
<div class="text-xs text-slate-500 mt-1">Creator Studio</div>
<div class="text-xs text-muted mt-1">Creator Studio</div>
</div>
</div>
</div>
@@ -57,7 +57,7 @@ const isFullWidth = computed(() => {
<router-link
:to="tenantRoute('/creator')"
exact-active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-surface-highlight hover:text-content transition-all group"
>
<i
class="pi pi-th-large text-lg group-hover:scale-110 transition-transform"
@@ -66,7 +66,7 @@ const isFullWidth = computed(() => {
</router-link>
<div
class="px-4 py-2 text-xs font-bold text-slate-500 uppercase tracking-wider mt-4"
class="px-4 py-2 text-xs font-bold text-muted uppercase tracking-wider mt-4"
>
内容与交易
</div>
@@ -74,7 +74,7 @@ const isFullWidth = computed(() => {
<router-link
:to="tenantRoute('/creator/contents')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-surface-highlight hover:text-content transition-all group"
>
<i
class="pi pi-file-edit text-lg group-hover:scale-110 transition-transform"
@@ -84,7 +84,7 @@ const isFullWidth = computed(() => {
<router-link
:to="tenantRoute('/creator/orders')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-surface-highlight hover:text-content transition-all group"
>
<i
class="pi pi-shopping-cart text-lg group-hover:scale-110 transition-transform"
@@ -94,7 +94,7 @@ const isFullWidth = computed(() => {
<router-link
:to="tenantRoute('/creator/coupons')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-surface-highlight hover:text-content transition-all group"
>
<i
class="pi pi-ticket text-lg group-hover:scale-110 transition-transform"
@@ -103,7 +103,7 @@ const isFullWidth = computed(() => {
</router-link>
<div
class="px-4 py-2 text-xs font-bold text-slate-500 uppercase tracking-wider mt-4"
class="px-4 py-2 text-xs font-bold text-muted uppercase tracking-wider mt-4"
>
配置
</div>
@@ -111,7 +111,7 @@ const isFullWidth = computed(() => {
<router-link
:to="tenantRoute('/creator/members')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-surface-highlight hover:text-content transition-all group"
>
<i
class="pi pi-users text-lg group-hover:scale-110 transition-transform"
@@ -121,7 +121,7 @@ const isFullWidth = computed(() => {
<router-link
:to="tenantRoute('/creator/settings')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-surface-highlight hover:text-content transition-all group"
>
<i
class="pi pi-cog text-lg group-hover:scale-110 transition-transform"
@@ -131,10 +131,10 @@ const isFullWidth = computed(() => {
</nav>
<!-- Footer Link -->
<div class="p-4 border-t border-slate-800">
<div class="p-4 border-t border-line">
<router-link
:to="tenantRoute('/')"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors"
class="flex items-center gap-2 px-4 py-2 text-sm text-muted hover:text-content transition-colors"
>
<i class="pi pi-external-link"></i> 预览我的主页
</router-link>

View File

@@ -4,15 +4,9 @@ import AppFooter from "../components/AppFooter.vue";
</script>
<template>
<div class="min-h-screen flex flex-col bg-slate-50 relative overflow-hidden">
<!-- Background Decor Blobs -->
<div
class="fixed top-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-100/40 rounded-full blur-[120px] -z-0"
></div>
<div
class="fixed bottom-[-10%] left-[-10%] w-[30%] h-[40%] bg-purple-50/50 rounded-full blur-[100px] -z-0"
></div>
<div
class="min-h-screen flex flex-col bg-base text-content relative overflow-hidden"
>
<TopNavbar class="relative z-10" />
<main class="flex-grow pt-16 relative z-10">
<router-view />

View File

@@ -11,30 +11,30 @@ const tenantRoute = (path) => tenantPath(path, route);
</script>
<template>
<div class="min-h-screen flex flex-col bg-slate-50">
<div class="min-h-screen flex flex-col bg-base text-content">
<TopNavbar />
<main class="flex-grow pt-16">
<div class="mx-auto max-w-screen-xl py-8 flex gap-8">
<!-- Sidebar -->
<aside class="w-[280px] flex-shrink-0 hidden lg:block">
<div
class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden sticky top-24"
class="bg-surface rounded-2xl shadow-sm border border-line overflow-hidden sticky top-24"
>
<!-- User Brief -->
<div class="p-6 border-b border-slate-100 bg-slate-50/50">
<div class="p-6 border-b border-line bg-surface-highlight">
<div class="flex items-center gap-4">
<img
:src="
user.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id || 'default'}`
"
class="w-12 h-12 rounded-full border-2 border-white shadow-sm"
class="w-12 h-12 rounded-full border-2 border-surface shadow-sm"
/>
<div class="overflow-hidden">
<div class="font-bold text-slate-900 truncate">
<div class="font-bold text-content truncate">
{{ user.nickname || "用户" }}
</div>
<div class="text-xs text-slate-500">ID: {{ user.id }}</div>
<div class="text-xs text-muted">ID: {{ user.id }}</div>
</div>
</div>
</div>
@@ -44,7 +44,7 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me')"
exact-active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-home text-lg"></i>
<span>概览</span>
@@ -52,7 +52,7 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me/orders')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-shopping-bag text-lg"></i>
<span>我的订单</span>
@@ -60,7 +60,7 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me/wallet')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-wallet text-lg"></i>
<span>我的钱包</span>
@@ -68,7 +68,7 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me/coupons')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-ticket text-lg"></i>
<span>我的优惠券</span>
@@ -76,7 +76,7 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me/library')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-book text-lg"></i>
<span>已购内容</span>
@@ -84,7 +84,7 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me/favorites')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-heart text-lg"></i>
<span>我的收藏</span>
@@ -92,7 +92,7 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me/likes')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-thumbs-up text-lg"></i>
<span>我的点赞</span>
@@ -100,16 +100,16 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me/notifications')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-bell text-lg"></i>
<span>消息中心</span>
</router-link>
<div class="my-2 border-t border-slate-100"></div>
<div class="my-2 border-t border-line"></div>
<router-link
:to="tenantRoute('/me/profile')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-user text-lg"></i>
<span>个人资料</span>
@@ -117,7 +117,7 @@ const tenantRoute = (path) => tenantPath(path, route);
<router-link
:to="tenantRoute('/me/security')"
active-class="bg-primary-50 text-primary-600 font-semibold"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-muted hover:bg-surface-highlight hover:text-content transition-colors"
>
<i class="pi pi-shield text-lg"></i>
<span>账号安全</span>

View File

@@ -21,7 +21,7 @@ app.use(PrimeVue, {
preset: Aura,
options: {
prefix: "p",
darkModeSelector: ".my-app-dark",
darkModeSelector: "[data-theme='senior-high-contrast']",
cssLayer: false,
},
},

View File

@@ -7,6 +7,32 @@ import LayoutCreator from "../layout/LayoutCreator.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
component: LayoutMain,
children: [
{
path: "",
component: () => import("../views/HomeView.vue"),
},
{
path: "contents/:id",
component: () => import("../views/content/DetailView.vue"),
},
{
path: "explore",
component: () => import("../views/ExploreView.vue"),
},
{
path: "topics",
component: () => import("../views/TopicsView.vue"),
},
{
path: "creator/apply",
component: () => import("../views/creator/ApplyView.vue"),
},
],
},
{
path: "/t/:tenantCode",
component: LayoutMain,
@@ -16,6 +42,11 @@ const router = createRouter({
name: "home",
component: () => import("../views/HomeView.vue"),
},
{
path: "channel",
name: "tenant-channel",
component: () => import("../views/tenant/HomeView.vue"),
},
{
path: "contents/:id",
name: "content-detail",

View File

@@ -1,7 +1,8 @@
<script setup>
import { ref, watch, onMounted } from "vue";
import { onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { contentApi } from "../api/content";
import { commonApi } from "../api/common";
import { tenantPath } from "../utils/tenant";
const route = useRoute();
@@ -12,6 +13,29 @@ const sort = ref("latest");
const keyword = ref("");
const contents = ref([]);
const page = ref(1);
const initializing = ref(true);
const STATIC_GENRES = [
"全部",
"京剧",
"昆曲",
"越剧",
"黄梅戏",
"豫剧",
"评剧",
"秦腔",
"河北梆子",
];
const filters = reactive({
genres: [...STATIC_GENRES],
prices: [
{ label: "全部", value: "all" },
{ label: "免费", value: "free" },
{ label: "付费", value: "paid" },
{ label: "会员专享", value: "member" },
],
});
const fetchContents = async (append = false) => {
const params = {
@@ -41,33 +65,59 @@ const loadMore = () => {
};
watch([selectedGenre, selectedPrice, sort], () => {
if (initializing.value) return;
page.value = 1;
fetchContents();
});
onMounted(() => {
fetchContents();
});
const fetchGenres = async () => {
try {
const res = await commonApi.getOptions();
const dynamicGenres = Array.from(
new Set(
(res?.content_genre || [])
.map((opt) => opt?.value)
.filter((value) => typeof value === "string" && value),
),
);
const filters = {
genres: [
"全部",
"京剧",
"昆曲",
"越剧",
"黄梅戏",
"豫剧",
"评剧",
"秦腔",
"河北梆子",
],
prices: [
{ label: "全部", value: "all" },
{ label: "免费", value: "free" },
{ label: "付费", value: "paid" },
{ label: "会员专享", value: "member" },
],
filters.genres = dynamicGenres.length
? ["全部", ...dynamicGenres]
: [...STATIC_GENRES];
} catch (e) {
console.error("Failed to fetch dynamic genres, using fallback", e);
filters.genres = [...STATIC_GENRES];
}
// 1. Sync from query if present
if (route.query.genre && filters.genres.includes(route.query.genre)) {
selectedGenre.value = route.query.genre;
}
// 2. Guard: if selected genre is no longer valid, reset to "全部"
else if (!filters.genres.includes(selectedGenre.value)) {
selectedGenre.value = "全部";
}
};
watch(
() => route.query.genre,
(newGenre) => {
if (newGenre && filters.genres.includes(newGenre)) {
selectedGenre.value = newGenre;
} else if (!newGenre) {
selectedGenre.value = "全部";
}
},
);
onMounted(async () => {
await fetchGenres();
// fetchContents handled by watcher or initial call if not triggered
if (initializing.value) {
initializing.value = false;
fetchContents();
}
});
</script>
<template>

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { commonApi } from "../api/common";
import { contentApi } from "../api/content";
import { tenantApi } from "../api/tenant";
import { tenantPath } from "../utils/tenant";
@@ -18,16 +19,129 @@ const page = ref(1);
const hasMore = ref(false);
const activeBannerIndex = ref(0);
const activeTab = ref("recommend");
const activeGenre = ref("");
const fallbackGenres = [
{ label: "全部", value: "" },
{ label: "京剧", value: "京剧" },
{ label: "昆曲", value: "昆曲" },
{ label: "越剧", value: "越剧" },
{ label: "名家名段", value: "名家名段" },
{ label: "戏曲教学", value: "戏曲教学" },
];
const genres = ref(fallbackGenres);
const getFeedSort = () => {
if (activeTab.value === "hot") return "hot";
return "latest";
};
const loadGenres = async () => {
try {
const res = await commonApi.getOptions();
const dynamicGenres = (res?.content_genre || [])
.filter((opt) => opt?.value)
.map((opt) => ({ label: opt.value, value: opt.value }));
genres.value = dynamicGenres.length
? [{ label: "全部", value: "" }, ...dynamicGenres]
: fallbackGenres;
if (!genres.value.some((item) => item.value === activeGenre.value)) {
activeGenre.value = "";
}
} catch {
genres.value = fallbackGenres;
if (!genres.value.some((item) => item.value === activeGenre.value)) {
activeGenre.value = "";
}
}
};
const getCreatorPath = (creator) => {
const code =
creator.tenant_code || creator.tenantCode || creator.code || creator.id;
return `/t/${code}`;
};
const getCreatorChannelPath = (creator) => {
const code = creator.tenant_code || creator.tenantCode || creator.code;
if (!code) return "";
const id = creator.id;
if (!Number.isInteger(id) || id <= 0) return "";
return `/t/${code}/channel`;
};
const getFeedParams = () => {
const params = {
page: page.value,
limit: 10,
sort: getFeedSort(),
keyword: searchKeyword.value,
};
if (activeGenre.value) {
params.genre = activeGenre.value;
}
return params;
};
const fetchFeed = async (isLoadMore = false) => {
if (!isLoadMore) {
loading.value = true;
page.value = 1;
}
try {
const res = await contentApi.list(getFeedParams());
if (isLoadMore) {
if (res.items) contents.value.push(...res.items);
} else {
contents.value = res.items || [];
}
hasMore.value = (res.total || 0) > contents.value.length;
} catch (e) {
console.error("Feed load error:", e);
} finally {
loading.value = false;
}
};
const switchTab = (tab) => {
if (activeTab.value === tab) return;
activeTab.value = tab;
fetchFeed();
};
const switchGenre = (genreValue) => {
if (activeGenre.value === genreValue) return;
activeGenre.value = genreValue;
fetchFeed();
};
const fetchData = async () => {
loading.value = true;
try {
const [bannerRes, trendingRes, creatorsRes, feedRes] = await Promise.all([
// Resilient loading using Promise.allSettled
const results = await Promise.allSettled([
contentApi.list({ is_pinned: true, limit: 5 }),
contentApi.list({ sort: "hot", limit: 3 }),
tenantApi.list({ limit: 5 }),
contentApi.list({ page: 1, limit: 10, sort: "latest" }),
]);
const getVal = (res, def = { items: [] }) =>
res.status === "fulfilled" ? res.value : def;
const bannerRes = getVal(results[0]);
const trendingRes = getVal(results[1]);
const creatorsRes = getVal(results[2]);
const feedRes = getVal(results[3]);
if (bannerRes.items && bannerRes.items.length > 0) {
bannerItems.value = bannerRes.items;
} else if (feedRes.items && feedRes.items.length > 0) {
@@ -38,7 +152,7 @@ const fetchData = async () => {
recommendedCreators.value = creatorsRes.items || [];
contents.value = feedRes.items || [];
hasMore.value = feedRes.total > contents.value.length;
hasMore.value = (feedRes.total || 0) > contents.value.length;
} catch (e) {
console.error(e);
} finally {
@@ -50,23 +164,25 @@ const handleSearch = async () => {
page.value = 1;
loading.value = true;
matchedCreators.value = [];
// Search resets tab context to 'recommend' (latest) effectively
// but we can just invoke fetchFeed with current tab if we want to respect it.
// Requirement says: "Feed requests respect current tab mode + keyword"
// So we do NOT reset activeTab here.
try {
const promises = [
contentApi.list({ page: 1, limit: 10, keyword: searchKeyword.value }),
];
const promises = [fetchFeed()];
if (searchKeyword.value) {
promises.push(tenantApi.list({ keyword: searchKeyword.value, limit: 5 }));
}
const results = await Promise.all(promises);
const contentRes = results[0];
contents.value = contentRes.items || [];
hasMore.value = contentRes.total > contents.value.length;
if (results[1]) {
matchedCreators.value = results[1].items || [];
promises.push(
tenantApi
.list({ keyword: searchKeyword.value, limit: 5 })
.then((res) => {
matchedCreators.value = res.items || [];
})
.catch((e) => console.error(e)),
);
}
await Promise.all(promises);
} finally {
loading.value = false;
}
@@ -74,18 +190,13 @@ const handleSearch = async () => {
const loadMore = async () => {
page.value++;
const res = await contentApi.list({
page: page.value,
limit: 10,
keyword: searchKeyword.value,
});
if (res.items) {
contents.value.push(...res.items);
hasMore.value = res.total > contents.value.length;
}
await fetchFeed(true);
};
onMounted(fetchData);
onMounted(async () => {
await loadGenres();
await fetchData();
});
</script>
<template>
@@ -168,17 +279,35 @@ onMounted(fetchData);
>
<div class="flex items-center gap-8">
<button
class="text-lg font-bold text-primary-600 border-b-2 border-primary-600 -mb-4.5 pb-4 px-2"
@click="switchTab('recommend')"
class="text-lg -mb-4.5 pb-4 px-2 transition-colors"
:class="[
activeTab === 'recommend'
? 'font-bold text-primary-600 border-b-2 border-primary-600'
: 'font-medium text-slate-500 hover:text-slate-800',
]"
>
推荐
</button>
<button
class="text-lg font-medium text-slate-500 hover:text-slate-800 -mb-4.5 pb-4 px-2 transition-colors"
@click="switchTab('latest')"
class="text-lg -mb-4.5 pb-4 px-2 transition-colors"
:class="[
activeTab === 'latest'
? 'font-bold text-primary-600 border-b-2 border-primary-600'
: 'font-medium text-slate-500 hover:text-slate-800',
]"
>
最新
</button>
<button
class="text-lg font-medium text-slate-500 hover:text-slate-800 -mb-4.5 pb-4 px-2 transition-colors"
@click="switchTab('hot')"
class="text-lg -mb-4.5 pb-4 px-2 transition-colors"
:class="[
activeTab === 'hot'
? 'font-bold text-primary-600 border-b-2 border-primary-600'
: 'font-medium text-slate-500 hover:text-slate-800',
]"
>
热门
</button>
@@ -201,34 +330,17 @@ onMounted(fetchData);
<!-- Tags -->
<div class="flex flex-wrap gap-3">
<button
class="px-4 py-1.5 rounded-full bg-slate-900 text-white text-sm font-medium"
v-for="genre in genres"
:key="genre.label"
@click="switchGenre(genre.value)"
class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors"
:class="[
activeGenre === genre.value
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200',
]"
>
全部
</button>
<button
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
>
京剧
</button>
<button
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
>
昆曲
</button>
<button
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
>
越剧
</button>
<button
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
>
名家名段
</button>
<button
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
>
戏曲教学
{{ genre.label }}
</button>
</div>
</div>
@@ -248,7 +360,7 @@ onMounted(fetchData);
v-for="creator in matchedCreators"
:key="creator.id"
class="flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors cursor-pointer border border-transparent hover:border-slate-200"
@click="$router.push(`/t/${creator.id}`)"
@click="$router.push(getCreatorPath(creator))"
>
<img
:src="
@@ -397,12 +509,12 @@ onMounted(fetchData);
`https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`
"
class="w-10 h-10 rounded-full cursor-pointer"
@click="$router.push(`/t/${creator.id}`)"
@click="$router.push(getCreatorPath(creator))"
/>
<div class="flex-1 min-w-0">
<div
class="font-bold text-slate-900 text-sm truncate hover:text-primary-600 cursor-pointer"
@click="$router.push(`/t/${creator.id}`)"
@click="$router.push(getCreatorPath(creator))"
>
{{ creator.name }}
</div>
@@ -410,11 +522,13 @@ onMounted(fetchData);
粉丝 {{ creator.stats?.followers || 0 }}
</div>
</div>
<button
<router-link
v-if="getCreatorChannelPath(creator)"
:to="getCreatorChannelPath(creator)"
class="px-3 py-1 bg-primary-50 text-primary-600 text-xs font-bold rounded-full hover:bg-primary-100"
>
关注
</button>
频道
</router-link>
</div>
<div
v-if="recommendedCreators.length === 0"

View File

@@ -1,11 +1,14 @@
<script setup>
import { ref } from "vue";
import { useRoute } from "vue-router";
import { ref, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { tenantPath } from "../utils/tenant";
import { contentApi } from "../api/content";
const route = useRoute();
const router = useRouter();
const tenantRoute = (path) => tenantPath(path, route);
const topics = ref([
const STATIC_TOPICS = [
{
id: 1,
title: "程派艺术赏析:幽咽婉转后的风骨",
@@ -56,7 +59,59 @@ const topics = ref([
date: "2024-12-05",
count: 15,
},
]);
];
const topics = ref([...STATIC_TOPICS]);
const KNOWN_GENRES = [
"京剧",
"昆曲",
"越剧",
"黄梅戏",
"豫剧",
"评剧",
"秦腔",
"河北梆子",
];
const findGenre = (topic) => {
const text = (topic.title + topic.tag).toLowerCase();
return KNOWN_GENRES.find((g) => text.includes(g));
};
const navigateToTopic = (topic) => {
const genre = findGenre(topic);
const target = tenantRoute("/explore");
if (genre) {
router.push({ path: target, query: { genre } });
} else {
// Fallback: navigate to explore without specific genre if no match found
router.push(target);
}
};
onMounted(async () => {
try {
const res = await contentApi.listTopics();
if (res && Array.isArray(res) && res.length > 0) {
topics.value = res.map((item, index) => ({
id: item.id || index + 100, // Safe ID
title: item.title || "未命名专题",
desc: item.desc || `探索${item.title || "精彩内容"}的更多详情。`,
cover:
item.cover ||
"https://images.unsplash.com/photo-1514306191717-452ec28c7f31?auto=format&fit=crop&w=800",
tag: item.tag || "精选",
date: item.date || new Date().toISOString().split("T")[0],
count: item.count || 0,
}));
}
} catch (err) {
console.warn("Failed to load topics, using fallback:", err);
// Keep STATIC_TOPICS
}
});
</script>
<template>
@@ -68,8 +123,9 @@ const topics = ref([
<!-- 1. Hero Topic -->
<div
v-if="topics.length > 0"
class="relative w-full h-[400px] rounded-2xl overflow-hidden mb-12 group cursor-pointer shadow-lg"
@click="$router.push(tenantRoute(`/explore?topic=${topics[0].id}`))"
@click="navigateToTopic(topics[0])"
>
<img
:src="topics[0].cover"
@@ -112,13 +168,16 @@ const topics = ref([
</div>
<!-- 2. Masonry-like Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
v-if="topics.length > 1"
>
<div
v-for="(topic, idx) in topics.slice(1)"
:key="topic.id"
class="group bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-xl transition-all cursor-pointer flex flex-col"
:class="{ 'lg:col-span-2 flex-row': idx === 0 }"
@click="$router.push(tenantRoute(`/explore?topic=${topic.id}`))"
@click="navigateToTopic(topic)"
>
<!-- Cover -->
<div

View File

@@ -1,14 +1,25 @@
<script setup>
import { useToast } from "primevue/usetoast";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { tenantApi } from "../../api/tenant";
import { useRoute, useRouter } from "vue-router";
import { contentApi } from "../../api/content";
import { tenantApi } from "../../api/tenant";
import { tenantPath } from "../../utils/tenant";
const route = useRoute();
const router = useRouter();
const tenantRoute = (path) => tenantPath(path, route);
const toast = useToast();
const tenantID = ref(0);
const resolveTenantIDFromRoute = () => {
const raw = route.params.id;
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
};
const showFollowActions = computed(() => tenantID.value > 0);
const currentTab = ref("home");
const isFollowing = ref(false);
const tenant = ref({});
@@ -25,46 +36,57 @@ const hasMore = ref(false);
const sortOption = ref("latest");
const selectedTopic = ref("");
const limit = 10;
const querySyncReady = ref(false);
const normalizeGenreQuery = (value) => {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value) && value.length > 0) {
return typeof value[0] === "string" ? value[0] : "";
}
return "";
};
const fetchData = async (isLoadMore = false) => {
if (!isLoadMore) loading.value = true;
try {
const id = route.params.id;
const effectiveTenantID = tenantID.value || resolveTenantIDFromRoute();
const query = {
tenant_id: id,
sort: sortOption.value,
page: page.value,
limit: limit,
keyword: searchKeyword.value,
};
if (effectiveTenantID > 0) {
query.tenant_id = effectiveTenantID;
}
if (selectedTopic.value) {
query.genre = selectedTopic.value;
}
const reqs = [contentApi.list(query)];
// Only fetch tenant info & featured on first load
// Only fetch featured on first load
if (!isLoadMore && page.value === 1) {
reqs.push(tenantApi.get(id));
reqs.push(
contentApi.list({
tenant_id: id,
is_pinned: true,
genre: selectedTopic.value || "",
}),
);
const pinnedQuery = {
is_pinned: true,
genre: selectedTopic.value || "",
};
if (effectiveTenantID > 0) {
pinnedQuery.tenant_id = effectiveTenantID;
}
reqs.push(contentApi.list(pinnedQuery));
}
const results = await Promise.all(reqs);
const c = results[0]; // Content List
if (!isLoadMore && page.value === 1) {
const t = results[1];
const f = results[2];
tenant.value = t || {};
isFollowing.value = t?.is_following || false;
const f = results[1];
if (f && f.items && f.items.length > 0) {
if (f?.items?.length > 0) {
featuredContent.value = f.items[0];
} else {
featuredContent.value = null;
@@ -92,12 +114,34 @@ const fetchData = async (isLoadMore = false) => {
}
};
onMounted(() => fetchData());
onMounted(async () => {
selectedTopic.value = normalizeGenreQuery(route.query.genre);
querySyncReady.value = true;
try {
topics.value = (await contentApi.listTopics()) || [];
const topicsRes = await contentApi.listTopics();
topics.value = topicsRes || [];
tenantID.value = resolveTenantIDFromRoute();
tenant.value = {
id: tenantID.value,
name: route.params.tenantCode,
bio: "",
cover: "",
avatar: "",
stats: {
followers: 0,
contents: 0,
likes: 0,
},
};
isFollowing.value = false;
fetchData();
} catch (e) {
console.error(e);
fetchData();
}
});
@@ -119,13 +163,25 @@ const loadMore = () => {
const toggleFollow = async () => {
if (followLoading.value) return;
const effectiveTenantID = tenantID.value || resolveTenantIDFromRoute();
if (!effectiveTenantID) {
toast.add({
severity: "warn",
summary: "暂不可用",
detail: "当前频道暂不支持关注操作",
life: 2000,
});
return;
}
followLoading.value = true;
try {
if (isFollowing.value) {
await tenantApi.unfollow(route.params.id);
await tenantApi.unfollow(effectiveTenantID);
isFollowing.value = false;
} else {
await tenantApi.follow(route.params.id);
await tenantApi.follow(effectiveTenantID);
isFollowing.value = true;
toast.add({
severity: "success",
@@ -156,9 +212,43 @@ const sortLabel = computed(() =>
sortOption.value === "hot" ? "最热" : "最新",
);
watch([sortOption, selectedTopic], () => {
applyFilters();
const currentTopicBreadcrumb = computed(() => {
const fromQuery = normalizeGenreQuery(route.query.genre);
if (fromQuery) return fromQuery;
if (selectedTopic.value) return selectedTopic.value;
return "全部专题";
});
watch(
() => route.query.genre,
(newGenre) => {
const normalized = normalizeGenreQuery(newGenre);
if (normalized !== selectedTopic.value) {
selectedTopic.value = normalized;
}
},
);
watch(
[sortOption, selectedTopic],
([nextSort, nextTopic], [prevSort, prevTopic]) => {
if (!querySyncReady.value) return;
if (nextTopic !== prevTopic) {
const nextQuery = { ...route.query };
if (nextTopic) {
nextQuery.genre = nextTopic;
} else {
delete nextQuery.genre;
}
router.replace({ query: nextQuery });
}
if (nextSort !== prevSort || nextTopic !== prevTopic) {
applyFilters();
}
},
);
</script>
<template>
@@ -229,6 +319,7 @@ watch([sortOption, selectedTopic], () => {
<div class="flex flex-col items-end gap-5 pb-2">
<div class="flex gap-3">
<button
v-if="showFollowActions"
@click="toggleFollow"
:disabled="followLoading"
class="h-11 w-32 rounded-full font-bold text-base transition-all flex items-center justify-center gap-2 backdrop-blur-md"
@@ -310,7 +401,16 @@ watch([sortOption, selectedTopic], () => {
<div
class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-14"
>
<div class="flex gap-8 h-full">
<div class="flex items-center gap-4 h-full">
<div
class="hidden md:flex items-center text-xs text-slate-500 bg-slate-100 rounded-full px-3 py-1"
>
<span>频道</span>
<i class="pi pi-angle-right text-[10px] mx-1.5"></i>
<span class="font-semibold text-slate-700">{{
currentTopicBreadcrumb
}}</span>
</div>
<button
v-for="tab in tabs"
:key="tab.value"
@@ -324,6 +424,12 @@ watch([sortOption, selectedTopic], () => {
>
{{ tab.label }}
</button>
<router-link
:to="tenantRoute('/topics')"
class="text-xs px-3 py-1.5 rounded-full border border-slate-200 text-slate-600 hover:border-primary-200 hover:text-primary-600 transition-colors"
>
去专题
</router-link>
</div>
<!-- In-Tenant Search -->

View File

@@ -11,7 +11,7 @@ export default defineConfig({
strictPort: true,
proxy: {
"/v1": {
target: "http://localhost:8080",
target: "http://localhost:18080",
changeOrigin: true,
},
},

View File

@@ -16,11 +16,11 @@ export default defineConfig({
host: '0.0.0.0',
proxy: {
'/super/v1': {
target: 'http://localhost:8080',
target: 'http://localhost:18080',
changeOrigin: true
},
'/v1': {
target: 'http://localhost:8080',
target: 'http://localhost:18080',
changeOrigin: true
}
}