Compare commits

...
5 Commits
111 changed files with 7059 additions and 11 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitea
.idea
.vscode
.DS_Store
*.log
tests
docs
web/node_modules
web/dist
coverage
+5 -5
View File
@@ -1,4 +1,4 @@
name: Deploy im-workbench (k3s test cluster)
name: Deploy olons-server (k3s test cluster)
on:
workflow_dispatch:
@@ -25,13 +25,13 @@ jobs:
- name: 构建并推送镜像
run: |
docker build -t git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }}
docker build -t git.118im.com/im_team/olons-server:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/olons-server:${{ steps.vars.outputs.tag }}
- name: 部署到k3s测试集群
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/im-workbench -n workbench-system
kubectl rollout status deployment/im-workbench -n workbench-system --timeout=120s
kubectl rollout restart deployment/olons-server -n olons-server-system
kubectl rollout status deployment/olons-server -n olons-server-system --timeout=120s
+2
View File
@@ -6,6 +6,7 @@ RUN corepack enable
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/ ./
ENV VITE_PUBLIC_PATH=/admin/
RUN pnpm run build
# ---- Stage 2: build the Go backend ----
@@ -21,6 +22,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench
FROM alpine:3.22
WORKDIR /app
RUN apk add --no-cache tzdata
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
COPY --from=builder /out/im_workbench /app/im_workbench
COPY configs /app/configs
+268
View File
@@ -0,0 +1,268 @@
package main
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log"
"os"
"regexp"
"strings"
"git.118im.com/im_team/im_workbench/internal/config"
_ "github.com/go-sql-driver/mysql"
)
var phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
type memberPhone struct {
id int64
tenantID int64
ciphertext string
hash string
mask string
phone string
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db, err := sql.Open("mysql", strings.TrimPrefix(cfg.MySQL.DSN, "mysql://"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
hasMobile, err := columnExists(ctx, db, "mobile")
if err != nil {
log.Fatal(err)
}
hasCipher, err := columnExists(ctx, db, "mobile_cipher")
if err != nil {
log.Fatal(err)
}
if hasMobile && !hasCipher {
log.Print("hg_app_member already uses the mobile column")
return
}
if !hasCipher {
log.Fatal("mobile_cipher is missing before migration")
}
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES"))
if err != nil {
log.Fatal(err)
}
if len(aeads) == 0 && len(lookupKeys) == 0 {
log.Fatal("no valid historical phone keys were provided")
}
phones, err := loadPhones(ctx, db, aeads, lookupKeys, overrides)
if err != nil {
log.Fatal(err)
}
if err := validateUnique(phones); err != nil {
log.Fatal(err)
}
if !hasMobile {
if _, err := db.ExecContext(ctx, `ALTER TABLE hg_app_member ADD COLUMN mobile varchar(20) NULL AFTER username`); err != nil {
log.Fatal(err)
}
}
if err := updatePhones(ctx, db, phones); err != nil {
log.Fatal(err)
}
if _, err := db.ExecContext(ctx, `ALTER TABLE hg_app_member
MODIFY COLUMN mobile varchar(20) NOT NULL,
DROP INDEX uk_app_member_mobile_hash,
ADD UNIQUE KEY uk_app_member_mobile (tenant_id,mobile),
DROP COLUMN mobile_cipher,
DROP COLUMN mobile_hash,
DROP COLUMN mobile_mask`); err != nil {
log.Fatal(err)
}
log.Printf("migrated %d member phone numbers", len(phones))
}
func columnExists(ctx context.Context, db *sql.DB, column string) (bool, error) {
var count int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='hg_app_member' AND COLUMN_NAME=?`, column).Scan(&count)
return count == 1, err
}
func loadPhones(ctx context.Context, db *sql.DB, aeads []cipher.AEAD, lookupKeys [][]byte, overrides map[int64]string) ([]memberPhone, error) {
rows, err := db.QueryContext(ctx, `SELECT id,tenant_id,mobile_cipher,mobile_hash,mobile_mask FROM hg_app_member ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
phones := make([]memberPhone, 0)
failed := make([]string, 0)
for rows.Next() {
var item memberPhone
if err := rows.Scan(&item.id, &item.tenantID, &item.ciphertext, &item.hash, &item.mask); err != nil {
return nil, err
}
item.phone = recoverPhone(item, aeads, lookupKeys)
if item.phone == "" {
item.phone = overrides[item.id]
}
if !phonePattern.MatchString(item.phone) {
failed = append(failed, fmt.Sprintf("%d", item.id))
} else if len(item.mask) == 11 && item.phone[:3]+"****"+item.phone[7:] != item.mask {
return nil, fmt.Errorf("member %d override does not match its phone mask", item.id)
}
phones = append(phones, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(failed) > 0 {
return nil, fmt.Errorf("phone recovery failed for member IDs: %s", strings.Join(failed, ","))
}
return phones, nil
}
func validateUnique(phones []memberPhone) error {
seen := make(map[string]int64, len(phones))
for _, item := range phones {
key := fmt.Sprintf("%d:%s", item.tenantID, item.phone)
if otherID, ok := seen[key]; ok {
return fmt.Errorf("members %d and %d have duplicate phone numbers", otherID, item.id)
}
seen[key] = item.id
}
return nil
}
func updatePhones(ctx context.Context, db *sql.DB, phones []memberPhone) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, item := range phones {
result, err := tx.ExecContext(ctx, `UPDATE hg_app_member SET mobile=? WHERE id=? AND tenant_id=?`, item.phone, item.id, item.tenantID)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("member %d update affected %d rows", item.id, affected)
}
}
return tx.Commit()
}
func splitKeys(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == '\n' })
}
func parseOverrides(value string) (map[int64]string, error) {
result := make(map[int64]string)
for _, pair := range splitKeys(value) {
var id int64
var phone string
if _, err := fmt.Sscanf(pair, "%d=%s", &id, &phone); err != nil || id <= 0 || !phonePattern.MatchString(phone) {
return nil, fmt.Errorf("invalid member phone override")
}
result[id] = phone
}
return result, nil
}
func newAEADs(encodedKeys []string) []cipher.AEAD {
result := make([]cipher.AEAD, 0, len(encodedKeys))
for _, encodedKey := range encodedKeys {
key, err := decodeKey(encodedKey)
if err != nil || len(key) != 32 {
continue
}
block, err := aes.NewCipher(key)
if err != nil {
continue
}
aead, err := cipher.NewGCM(block)
if err == nil {
result = append(result, aead)
}
}
return result
}
func decodeKeys(encodedKeys []string) [][]byte {
result := make([][]byte, 0, len(encodedKeys))
for _, encodedKey := range encodedKeys {
key, err := decodeKey(encodedKey)
if err == nil && len(key) >= 32 {
result = append(result, key)
}
}
return result
}
func decodeKey(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.RawStdEncoding.DecodeString(value)
}
func recoverPhone(item memberPhone, aeads []cipher.AEAD, lookupKeys [][]byte) string {
for _, aead := range aeads {
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.RawURLEncoding} {
plain, err := decrypt(aead, encoding, item.ciphertext)
phone := strings.TrimPrefix(plain, "+86")
if err == nil && phonePattern.MatchString(phone) {
return phone
}
}
}
if len(item.mask) != 11 || item.mask[3:7] != "****" {
return ""
}
for middle := 0; middle < 10000; middle++ {
phone := fmt.Sprintf("%s%04d%s", item.mask[:3], middle, item.mask[7:])
for _, value := range []string{phone, "+86" + phone} {
digest := sha256.Sum256([]byte(value))
if hmac.Equal([]byte(strings.ToLower(item.hash)), []byte(hex.EncodeToString(digest[:]))) {
return phone
}
}
for _, key := range lookupKeys {
for _, value := range []string{phone, "+86" + phone} {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write([]byte(value))
if hmac.Equal([]byte(strings.ToLower(item.hash)), []byte(hex.EncodeToString(mac.Sum(nil)))) {
return phone
}
}
}
}
return ""
}
func decrypt(aead cipher.AEAD, encoding *base64.Encoding, value string) (string, error) {
raw, err := encoding.DecodeString(value)
if err != nil || len(raw) < aead.NonceSize() {
return "", errors.New("invalid ciphertext")
}
plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
return string(plain), err
}
@@ -0,0 +1,130 @@
# Olons Server 测试集群部署 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将 `olons_server` 的 `dev` 分支以独立前后端一体服务部署到现有 k3s 测试集群,并通过 `https://olns-web.118im.com` 验收。
**Architecture:** 保留仓库现有 Vue 构建产物嵌入 Go 服务的单镜像结构。新增独立 GitOps 应用 `olons-server-system/olons-server`,复用现有 PXC、Redis、Traefik、证书和镜像仓库,通过 Secret 注入运行时配置,不改动现有 `im-workbench`。
**Tech Stack:** Go 1.26、Vue 3/Vite、Docker、Kubernetes/Kustomize、Argo CD、Traefik、PXC、Redis、Python unittest/PyYAML。
---
### Task 1: 固定应用仓库部署契约
**Files:**
- Create: `tests/test_deployment_contract.py`
- Create: `.dockerignore`
- Modify: `.gitea/workflows/deploy.yml`
- [ ] **Step 1: Write the failing test**
新增测试,断言工作流名称、镜像名、Namespace、Deployment、Docker 前后端一体构建以及 `.dockerignore` 的必要条目。
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m unittest tests/test_deployment_contract.py -v`
Expected: FAIL,旧工作流仍引用 `im-workbench` 和 `workbench-system`,且 `.dockerignore` 不存在。
- [ ] **Step 3: Write minimal implementation**
将工作流镜像改为 `git.118im.com/im_team/olons-server:<tag>`,滚动目标改为 `olons-server-system/olons-server`;新增仅排除本地和构建垃圾的 `.dockerignore`。
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 -m unittest tests/test_deployment_contract.py -v`
Expected: PASS。
- [ ] **Step 5: Verify application builds**
Run: `docker run --rm -v "$PWD":/src -w /src golang:1.26 go test ./...`
Run: `docker build --platform linux/amd64 -t local/olons-server:test .`
Expected: 两条命令均退出 0。
### Task 2: 新增独立 GitOps 应用与公网路由
**Files:**
- Create: `/Users/aa/workspace/imcn/im-deploy-config/tests/test_olons_server.py`
- Create: `/Users/aa/workspace/imcn/im-deploy-config/apps/olons-server/base/deployment.yaml`
- Create: `/Users/aa/workspace/imcn/im-deploy-config/apps/olons-server/base/service.yaml`
- Create: `/Users/aa/workspace/imcn/im-deploy-config/apps/olons-server/base/kustomization.yaml`
- Create: `/Users/aa/workspace/imcn/im-deploy-config/apps/olons-server/overlays/test/kustomization.yaml`
- Modify: `/Users/aa/workspace/imcn/im-deploy-config/platform/ingress/routes.yaml`
- Modify: `/Users/aa/workspace/imcn/im-deploy-config/platform/ingress/certificate.yaml`
- Modify: `/Users/aa/workspace/imcn/im-deploy-config/platform/ci/rbac.yaml`
- [ ] **Step 1: Write the failing test**
新增测试,断言一副本、小资源、6061 探针、Secret 注入、无 PVC、测试镜像、专用 Host 路由、根路径重定向、证书域名及 CI RoleBinding。
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m unittest tests/test_olons_server.py -v`
Expected: FAIL,目标 Kustomize 目录和专用路由尚不存在。
- [ ] **Step 3: Write minimal implementation**
新增 Deployment、Service 与 test overlay;在现有 Traefik IngressRoute 中加入优先级 20 的 `olns-web.118im.com` 路由和根路径重定向中间件;补充证书 DNS 名称及 CI RoleBinding。
- [ ] **Step 4: Run tests to verify they pass**
Run: `python3 -m unittest discover -s tests -v`
Expected: 全部 PASS。
- [ ] **Step 5: Render and inspect manifests**
Run: `kubectl kustomize apps/olons-server/overlays/test`
Expected: 只渲染 `olons-server` Service 与 Deployment,不含 Secret 明文和 PVC。
### Task 3: 推送、构建镜像与创建运行时 Secret
**Files:**
- No repository file changes.
- [ ] **Step 1: Re-fetch both remotes**
确认远端无新提交后再推送,避免覆盖开发更新。
- [ ] **Step 2: Commit and push application and GitOps changes**
分别推送 `olons_server/dev` 与 `im-deploy-config/main`,不强推。
- [ ] **Step 3: Build and push amd64 image**
构建并推送 `git.118im.com/im_team/olons-server:test`,记录远端 digest。
- [ ] **Step 4: Create scoped runtime secrets**
在 `olons-server-system` 创建 `gitea-registry` 与 `olons-server-secrets`。从现有集群配置安全取得 PXC/Redis 参数,将后台 `security.dataKey` 按当前算法派生为应用 AES/HMAC key,生成独立 API Key 和 Redis 会话前缀;不得输出任何值。
### Task 4: 同步并完成集群和公网验收
**Files:**
- Create: `/Users/aa/.codex/memories/extensions/ad_hoc/notes/<timestamp>-olons-server-test-deployment.md`
- [ ] **Step 1: Wait for Argo CD synchronization**
确认 `olons-server-test`、`platform-ingress`、`platform-ci` 为 `Synced/Healthy`。
- [ ] **Step 2: Verify Kubernetes runtime**
确认 Deployment 1/1 Available,Pod Ready、零异常重启,Service/Endpoint 正常,容器使用本次镜像 digest。
- [ ] **Step 3: Verify middleware isolation**
确认 PXC、Redis、现有 `im-workbench` 和 `olons-admin` 持续健康且未被重启。
- [ ] **Step 4: Verify public behavior**
确认 `/` 跳转 `/admin/`、`/healthz` 200、TLS 有效;在浏览器检查前端无控制台错误,并以真实 API 完成注册、登录和一个会话只读接口。
- [ ] **Step 5: Run final verification and write memory note**
重新运行两个仓库测试、源码测试/构建及公网检查;将实际提交、镜像 digest、Argo/Pod/API 结果和未覆盖项写入记忆扩展文件,且不得记录 Secret。
@@ -0,0 +1,105 @@
# Olons Server 测试集群部署设计
## 目标
将 `olons_server` 的 `dev` 分支作为前后端一体应用部署到现有 k3s 测试集群,通过 `https://olns-web.118im.com` 对外服务。部署复用现有 PXC、Redis、Traefik、证书体系和 Gitea 镜像仓库,不替换或重启现有 `im-workbench`,也不新增数据库、中间件或持久卷。
## 已确认的源码基线
- 部署分支:`dev`。
- 验证提交:`96d1de4a95e419433d18be6552422450ec85b10a`。
- Vue 前端在 Docker 第一阶段构建,产物复制进 Go 运行镜像。
- Go 服务监听 `6061`,在 `/admin/` 提供前端,在 `/v1/*` 提供业务 API,并暴露 `/healthz` 与 `/metrics`。
- 前端生产构建、Go 全量测试和完整 Docker 构建均已在隔离环境通过。
- 应用只允许连接 `olons_admin` 数据库。
## 方案选择
采用独立应用部署,不复用现有 `workbench-system/im-workbench` Deployment。
未采用的方案:
- 覆盖现有 `im-workbench`:会影响已运行的 `/v1/workbench` 服务,风险不可接受。
- 前后端拆分部署:与仓库现有一体化构建和同源 API 设计不一致,增加不必要的镜像、路由和发布复杂度。
## 应用仓库变更
在 `olons_server` 中完成以下最小变更:
1. 新增部署契约测试,先固定镜像名、Namespace、发布目标和一体化构建要求。
2. 增加 `.dockerignore`,排除 Git 元数据、本地依赖、构建产物、测试缓存和开发文件。
3. 保持现有多阶段 Docker 构建;运行镜像仍只包含 Go 二进制、配置模板和前端产物。
4. 将 Gitea Actions 工作流改为构建并推送 `git.118im.com/im_team/olons-server:test`。
5. 工作流只滚动更新 `olons-server-system/olons-server`,不得操作现有 `workbench-system/im-workbench`。
## GitOps 资源
在 `im-deploy-config` 中新增独立应用:
- Namespace:`olons-server-system`
- Deployment:`olons-server`
- Service:`olons-server`
- 容器端口:`6061`
- 副本数:`1`
- 镜像:`git.118im.com/im_team/olons-server:test`
- `imagePullPolicy`:`Always`
- requests:`50m CPU / 64Mi memory`
- limits:`500m CPU / 256Mi memory`
- 不创建 PVC
探针:
- startup:TCP `6061`
- readiness:HTTP `GET /healthz`
- liveness:TCP `6061`
配置通过 Kubernetes Secret 环境变量注入,Git 仓库只保存 Secret 名称和键引用,不保存任何凭据值。
## 数据库、Redis 与密钥
- MySQL 复用现有 PXC 中的 `olons_admin` 数据库。
- 使用已有最小权限应用账号;如现有账号权限不足,只补充本应用实际需要的表级权限。
- Redis 复用现有高可用服务,不修改或重启 Redis。
- 数据加密密钥与 HMAC 查找密钥从现有兼容配置安全复制到新 Namespace,保证已有敏感数据可解密和检索。
- 为新服务创建独立 API Key。
- Redis 会话键使用独立前缀,避免与现有 `im-workbench` 会话冲突。
- Secret 值不输出到日志、不写入仓库、不写入部署记录。
## 公网路由
在现有 Traefik GitOps 配置中增加高于通配回退规则的专用 Host 路由:
- Host:`olns-web.118im.com`
- `/admin/`、`/v1/`、`/healthz` 以及前端静态资源均转发到 `olons-server-system/olons-server:6061`
- `/` 重定向到 `/admin/`
将 `olns-web.118im.com` 加入现有证书资源。域名当前已通过 Cloudflare 解析到集群入口,但专用路由上线前命中的是默认 Web 应用。
## 发布顺序
1. 在两个仓库分别增加失败的部署契约测试。
2. 完成应用发布配置和 GitOps 清单,使测试转绿。
3. 运行 Go 测试、前端构建、Docker 构建、应用部署契约测试和 GitOps 测试。
4. 提交并推送 `olons_server/dev` 与 `im-deploy-config/main`。
5. 在 `olons-server-system` 创建 Registry Secret 和应用配置 Secret。
6. 构建并推送 amd64 测试镜像。
7. 创建或同步 Argo CD Application,等待 Synced/Healthy。
8. 验证 Pod、Service、Endpoint、数据库、Redis、TLS、页面与 API。
## 验收标准
- Argo CD 应用为 `Synced/Healthy`。
- Deployment 为 `1/1 Available`,Pod Ready 且无异常重启。
- 实际运行镜像为本次构建的 `olons-server:test` digest。
- `/healthz` 在集群内和公网均返回 200。
- `https://olns-web.118im.com/` 重定向到 `/admin/`。
- 浏览器打开前端无控制台错误,静态资源全部成功加载。
- 注册、登录及至少一个需要会话的只读业务接口通过真实请求验证。
- PXC 和 Redis 保持健康,现有 `im-workbench`、`olons-admin` 及其他 Argo 应用不受影响。
- 仓库中不存在明文 Secret。
## 回滚
- 应用回滚到前一镜像 digest 或停止新 Deployment,不触碰共享中间件。
- GitOps 路由异常时回退新增的 Host 路由和证书项,恢复原通配回退行为。
- 不通过删除数据库记录、清空 Redis 或重启 PXC/Redis实施回滚。
+6 -6
View File
@@ -256,14 +256,14 @@ func parseFeatures(raw json.RawMessage) Features {
return Features{}
}
return Features{
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
TaskEnabled: findBool(config, true, "task_enabled", "taskEnabled", "TASK"),
RechargeEnabled: findBool(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
WithdrawalEnabled: findBool(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
ManualMallEnabled: findBool(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
}
}
func findBool(config map[string]any, keys ...string) bool {
func findBool(config map[string]any, fallback bool, keys ...string) bool {
for _, key := range keys {
if value, ok := config[key]; ok {
return boolValue(value)
@@ -276,7 +276,7 @@ func findBool(config map[string]any, keys ...string) bool {
}
}
}
return false
return fallback
}
func boolValue(value any) bool {
+7
View File
@@ -61,6 +61,13 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
}
}
func TestParseFeaturesDefaultsToEnabled(t *testing.T) {
features := parseFeatures(json.RawMessage(`{"siteName":"demo"}`))
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled {
t.Fatalf("unexpected feature defaults: %+v", features)
}
}
func TestBannersSanitizeTargets(t *testing.T) {
service := New(fakeRepository{banners: []store.Banner{
{ID: 1, TargetType: 2, TargetValue: "/receive"},
+167
View File
@@ -0,0 +1,167 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
)
type RechargeChannel struct {
ID int64 `db:"id"`
Code string `db:"channel_code"`
Name string `db:"name"`
ChannelType int `db:"channel_type"`
MinAmount int64 `db:"min_amount"`
MaxAmount int64 `db:"max_amount"`
FeeType int `db:"fee_type"`
FeeFixedAmount int64 `db:"fee_fixed_amount"`
FeeRate int `db:"fee_rate"`
}
type RechargeCreation struct {
TenantID int64
MemberID int64
RechargeNo string
ChannelID int64
RequestAmount int64
Currency string
VoucherAttachmentIDs []int64
Remark string
}
type RechargeOrder struct {
ID int64 `db:"id"`
RechargeNo string `db:"recharge_no"`
ChannelID int64 `db:"channel_id"`
ChannelName string `db:"channel_name"`
RequestAmount int64 `db:"request_amount"`
PaidAmount int64 `db:"paid_amount"`
Currency string `db:"currency"`
VoucherAttachmentIDs json.RawMessage `db:"voucher_attachment_ids"`
Status int `db:"recharge_status"`
Remark string `db:"remark"`
ReviewReasonCode string `db:"review_reason_code"`
ReviewReason string `db:"review_reason"`
ExternalNo string `db:"external_no"`
PaidAt string `db:"paid_at"`
ReviewedAt string `db:"reviewed_at"`
CreditedAt string `db:"credited_at"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
type RechargePage struct {
Items []RechargeOrder
Total int64
}
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
items := make([]RechargeChannel, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel
WHERE tenant_id=? AND direction IN (1,3) AND status=1 AND deleted_at IS NULL ORDER BY sort,id`, tenantID)
return items, err
}
func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*RechargeOrder, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var memberID int64
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountRestricted
}
if err != nil {
return nil, err
}
var channel RechargeChannel
err = tx.GetContext(ctx, &channel, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel WHERE tenant_id=? AND id=?
AND direction IN (1,3) AND status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.ChannelID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRechargeChannel
}
if err != nil {
return nil, err
}
if input.RequestAmount < maxInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.RequestAmount > channel.MaxAmount) {
return nil, ErrRechargeAmount
}
for _, attachmentID := range input.VoucherAttachmentIDs {
var count int
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
AND app_id='workbench' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
return nil, err
}
if count != 1 {
return nil, ErrRechargeVoucher
}
}
vouchers, err := json.Marshal(input.VoucherAttachmentIDs)
if err != nil {
return nil, err
}
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
VoucherAttachmentIDs: vouchers, Status: 1, Remark: input.Remark}, nil
}
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
where := ` WHERE o.tenant_id=? AND o.member_id=?`
args := []any{tenantID, memberID}
if status > 0 {
where += ` AND o.recharge_status=?`
args = append(args, status)
}
var total int64
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_recharge_order o`+where, args...); err != nil {
return nil, err
}
args = append(args, limit, offset)
items := make([]RechargeOrder, 0)
err := s.db.SelectContext(ctx, &items, rechargeSelectSQL()+where+
` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
return &RechargePage{Items: items, Total: total}, err
}
func (s *Store) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*RechargeOrder, error) {
var row RechargeOrder
err := s.db.GetContext(ctx, &row, rechargeSelectSQL()+
` WHERE o.tenant_id=? AND o.member_id=? AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRechargeNotFound
}
return &row, err
}
func rechargeSelectSQL() string {
return `SELECT o.id,o.recharge_no,o.channel_id,COALESCE(c.name,'') AS channel_name,o.request_amount,
o.paid_amount,o.currency,COALESCE(o.voucher_attachment_ids,JSON_ARRAY()) AS voucher_attachment_ids,
o.recharge_status,o.remark,o.review_reason_code,o.review_reason,o.external_no,
COALESCE(DATE_FORMAT(o.paid_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS paid_at,
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
COALESCE(DATE_FORMAT(o.credited_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS credited_at,
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_recharge_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
}
+44
View File
@@ -0,0 +1,44 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class DeploymentContractTest(unittest.TestCase):
def test_workflow_targets_only_olons_server(self):
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
self.assertIn("Deploy olons-server (k3s test cluster)", workflow)
self.assertIn("git.118im.com/im_team/olons-server:", workflow)
self.assertIn("deployment/olons-server -n olons-server-system", workflow)
self.assertNotIn("im-workbench", workflow)
self.assertNotIn("workbench-system", workflow)
def test_dockerfile_builds_frontend_into_backend_image(self):
dockerfile = (ROOT / "Dockerfile").read_text()
self.assertIn("RUN pnpm run build", dockerfile)
self.assertIn("VITE_PUBLIC_PATH=/admin/", dockerfile)
self.assertIn("COPY --from=web-builder /web/dist ./web/dist", dockerfile)
self.assertIn("COPY --from=web-builder /web/dist /app/web/dist", dockerfile)
self.assertIn("EXPOSE 6061", dockerfile)
def test_runtime_image_includes_timezone_database_for_mysql_dsn(self):
dockerfile = (ROOT / "Dockerfile").read_text()
self.assertIn("RUN apk add --no-cache tzdata", dockerfile)
def test_docker_context_excludes_local_artifacts(self):
entries = {
line.strip()
for line in (ROOT / ".dockerignore").read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
for required in {".git", "web/node_modules", "web/dist", "*.log"}:
self.assertIn(required, entries)
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
import type { MessageApi } from 'naive-ui';
declare global {
interface Window {
$message: MessageApi;
}
}
export {};
+73
View File
@@ -0,0 +1,73 @@
<template>
<main class="m-app" :class="{ 'm-app--with-nav': showNav }">
<router-view />
<nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航">
<router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label">
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
<span>{{ item.label }}</span>
</router-link>
</nav>
</main>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { hasSession } from './api';
import { mobileAssets } from './assets';
const route = useRoute();
const router = useRouter();
const showNav = computed(() => Boolean(route.meta.mobileNav));
const nav = [
{
label: '首页',
path: '/app/home',
icon: mobileAssets.navHome,
activeIcon: mobileAssets.navHomeActive,
},
{
label: '记录',
path: '/app/orders',
icon: mobileAssets.navOrders,
activeIcon: mobileAssets.navOrdersActive,
},
{
label: '领取',
path: '/app/claim',
icon: mobileAssets.navClaimActive,
activeIcon: mobileAssets.navClaimActive,
},
{
label: '客服',
path: '/app/support',
icon: mobileAssets.navSupport,
activeIcon: mobileAssets.navSupportActive,
},
{
label: '我的',
path: '/app/profile',
icon: mobileAssets.navProfile,
activeIcon: mobileAssets.navProfileActive,
},
];
const isActive = (path: string) => route.path === path || route.path.startsWith(`${path}/`);
function ensureAccess() {
const publicContent = [
'/app/content/user_agreement',
'/app/content/privacy_policy',
'/app/content/platform_intro',
];
const publicPassword = ['/app/password/verify', '/app/password/reset'];
if (
!hasSession() &&
!publicContent.includes(route.path) &&
!publicPassword.includes(route.path)
) {
router.replace({ path: '/app/auth', query: { redirect: route.fullPath } });
}
}
watch(() => route.path, ensureAccess, { immediate: true });
</script>
+188
View File
@@ -0,0 +1,188 @@
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
const TOKEN_KEY = 'olons_web_session';
const DEVICE_KEY = 'olons_web_device';
const reasonMessages: Record<string, string> = {
AUTH_REQUIRED: '登录已失效,请重新登录',
FORBIDDEN: '当前账户无权执行此操作',
INVALID_CREDENTIALS: '手机号或密码错误',
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
RATE_LIMITED: '操作过于频繁,请稍后再试',
TASK_DISABLED: '领取功能暂未开放',
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
INSUFFICIENT_BALANCE: '可用余额不足',
DAILY_LIMIT_REACHED: '今日领取次数已用完',
PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理',
NO_TASK_AVAILABLE: '当前暂无可领取任务',
ORDER_EXPIRED: '该任务已超时',
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
TRADE_PASSWORD_INVALID: '支付密码不正确',
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
WITHDRAWAL_DISABLED: '取出功能暂未开放',
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
WITHDRAWAL_DAILY_LIMIT_REACHED: '今日取出次数已用完',
WITHDRAWAL_CHANNEL_UNAVAILABLE: '当前没有可用的取出通道',
RECHARGE_AMOUNT_INVALID: '储入金额不符合当前通道规则',
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
INTERNAL: '服务异常,请稍后重试',
};
export interface ApiEnvelope<T> {
code: number;
data: T;
message?: string;
reason?: string;
request_id?: string;
server_time?: string;
}
export class MobileApiError extends Error {
reason: string;
status: number;
constructor(message: string, reason = '', status = 0) {
super(message);
this.reason = reason;
this.status = status;
}
}
const client = axios.create({
baseURL: '',
timeout: 15000,
headers: { Accept: 'application/json' },
});
client.interceptors.request.use((config) => {
const token = sessionStorage.getItem(TOKEN_KEY);
if (token) config.headers.Authorization = `Bearer ${token}`;
config.headers['X-Request-ID'] = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
config.headers['X-Device-ID'] = getDeviceID();
return config;
});
client.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiEnvelope<unknown>>) => {
const status = error.response?.status || 0;
const payload = error.response?.data;
if (status === 401) {
clearSession();
const currentPath = window.location.hash.replace(/^#/, '') || '/app/home';
if (!currentPath.startsWith('/app/auth')) {
window.location.hash = `/app/auth?redirect=${encodeURIComponent(currentPath)}`;
}
}
const reason = payload?.reason || '';
return Promise.reject(
new MobileApiError(
reasonMessages[reason] ||
payload?.message ||
(status ? '请求失败,请稍后重试' : '网络连接失败'),
reason,
status
)
);
}
);
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
const method = String(config.method || 'GET').toUpperCase();
const attempts = method === 'GET' ? 3 : 1;
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await client.request<ApiEnvelope<T>>(config);
if (response.data.code !== 0) {
const reason = response.data.reason || '';
throw new MobileApiError(
reasonMessages[reason] || response.data.message || '请求失败',
reason,
response.status
);
}
return response.data.data;
} catch (error) {
lastError = error;
if (!shouldRetry(error, attempt, attempts)) throw error;
await new Promise((resolve) => window.setTimeout(resolve, attempt === 0 ? 250 : 600));
}
}
throw lastError;
}
function shouldRetry(error: unknown, attempt: number, attempts: number) {
if (attempt >= attempts - 1) return false;
if (axios.isCancel(error)) return false;
return error instanceof MobileApiError && (error.status === 0 || error.status === 503);
}
function getDeviceID() {
let value = localStorage.getItem(DEVICE_KEY);
if (!value) {
value = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem(DEVICE_KEY, value);
}
return value;
}
export function saveSession(token: string) {
sessionStorage.setItem(TOKEN_KEY, token);
}
export function clearSession() {
sessionStorage.removeItem(TOKEN_KEY);
}
export function hasSession() {
return Boolean(sessionStorage.getItem(TOKEN_KEY));
}
export function idempotencyKey() {
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export function money(value?: number, currency = 'CNY') {
const amount = Number.isFinite(value) ? Number(value) / 100 : 0;
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount);
}
export function dateTime(value?: string) {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
.format(date)
.replaceAll('/', '-');
}
export function safeImage(url?: string) {
if (!url) return '';
try {
const parsed = new URL(url, window.location.origin);
return parsed.protocol === 'https:' || parsed.origin === window.location.origin
? parsed.href
: '';
} catch {
return '';
}
}
export function errorText(error: unknown) {
return error instanceof Error ? error.message : '操作失败,请稍后重试';
}
+153
View File
@@ -0,0 +1,153 @@
import homeHero from './assets/home-hero.png';
import homeBalance from './assets/home-balance.png';
import shortcutClaim from './assets/shortcut-claim.png';
import shortcutRecharge from './assets/shortcut-recharge.png';
import shortcutWithdraw from './assets/shortcut-withdraw.png';
import shortcutMall from './assets/shortcut-mall.png';
import navHome from './assets/nav-home.png';
import navHomeActive from './assets/nav-home-active.png';
import navOrders from './assets/nav-orders.png';
import navOrdersActive from './assets/nav-orders-active.png';
import navClaimActive from './assets/nav-claim-active.png';
import navSupport from './assets/nav-support.png';
import navSupportActive from './assets/nav-support-active.png';
import navProfile from './assets/nav-profile.png';
import navProfileActive from './assets/nav-profile-active.png';
import profileBackground from './assets/profile-bg.png';
import profileWallet from './assets/profile-wallet.png';
import menuProfile from './assets/menu-profile.png';
import menuRecharge from './assets/menu-recharge.png';
import menuWithdraw from './assets/menu-withdraw.png';
import menuLedger from './assets/menu-ledger.png';
import menuNotices from './assets/menu-notices.png';
import menuLogout from './assets/menu-logout.png';
import copy from './assets/copy.png';
import supportHero from './assets/support-hero.png';
import back from './assets/back.png';
import claimOrbit from './assets/claim-orbit.png';
import claimGift from './assets/claim-gift.png';
import claimButton from './assets/claim-button.png';
import processSimple from './assets/process-simple.png';
import processMaterial from './assets/process-material.png';
import processTime from './assets/process-time.png';
import processReturn from './assets/process-return.png';
import chatVoice from './assets/chat-voice.png';
import chatMore from './assets/chat-more.png';
import companyPlatform from './assets/company-platform.png';
import companyRules from './assets/company-rules.png';
import companyQualification from './assets/company-qualification.png';
import companyAgency from './assets/company-agency.png';
import avatarEdit from './assets/avatar-edit.png';
import rechargeRecord from './assets/recharge-record.png';
import withdrawalRecord from './assets/withdrawal-record.png';
import loadingOrbit from './assets/loading-orbit.png';
import agencyHero from './assets/agency-hero.png';
import agencyBackground from './assets/agency-bg.png';
import agencyPersonPrimary from './assets/agency-person-primary.png';
import agencyPersonSecondary from './assets/agency-person-secondary.png';
import agencyPersonTertiary from './assets/agency-person-tertiary.png';
import rulesBackground from './assets/rules-bg.png';
import rulesBanner from './assets/rules-banner.png';
import levelSilver from './assets/level-silver.png';
import levelGold from './assets/level-gold.png';
import levelPlatinum from './assets/level-platinum.png';
import levelDiamond from './assets/level-diamond.png';
import levelStar from './assets/level-star.png';
import levelCrown from './assets/level-crown.png';
import claimMachine from './assets/claim-machine.png';
import claimAutoButton from './assets/claim-auto-button.png';
import claimTotal from './assets/claim-total.png';
import claimYesterday from './assets/claim-yesterday.png';
import claimToday from './assets/claim-today.png';
import claimCount from './assets/claim-count.png';
import claimSpinner from './assets/claim-spinner.png';
import claimMatchingGift from './assets/claim-matching-gift.png';
import claimSuccess from './assets/claim-success.png';
import claimFailed from './assets/claim-failed.png';
import claimCancelled from './assets/claim-cancelled.png';
import mallBanner from './assets/mall-banner.png';
import mallBackground from './assets/mall-bg.png';
import rankFirst from './assets/rank-first.png';
import rankSecond from './assets/rank-second.png';
import rankThird from './assets/rank-third.png';
import rankBackground from './assets/rank-bg.png';
import qualificationOperation from './assets/qualification-operation.png';
import qualificationDevelopment from './assets/qualification-development.png';
export const mobileAssets = {
homeHero,
homeBalance,
shortcutClaim,
shortcutRecharge,
shortcutWithdraw,
shortcutMall,
navHome,
navHomeActive,
navOrders,
navOrdersActive,
navClaimActive,
navSupport,
navSupportActive,
navProfile,
navProfileActive,
profileBackground,
profileWallet,
menuProfile,
menuRecharge,
menuWithdraw,
menuLedger,
menuNotices,
menuLogout,
copy,
supportHero,
back,
claimOrbit,
claimGift,
claimButton,
processSimple,
processMaterial,
processTime,
processReturn,
chatVoice,
chatMore,
companyPlatform,
companyRules,
companyQualification,
companyAgency,
avatarEdit,
rechargeRecord,
withdrawalRecord,
loadingOrbit,
agencyHero,
agencyBackground,
agencyPersonPrimary,
agencyPersonSecondary,
agencyPersonTertiary,
rulesBackground,
rulesBanner,
levelSilver,
levelGold,
levelPlatinum,
levelDiamond,
levelStar,
levelCrown,
claimMachine,
claimAutoButton,
claimTotal,
claimYesterday,
claimToday,
claimCount,
claimSpinner,
claimMatchingGift,
claimSuccess,
claimFailed,
claimCancelled,
mallBanner,
mallBackground,
rankFirst,
rankSecond,
rankThird,
rankBackground,
qualificationOperation,
qualificationDevelopment,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+27
View File
@@ -0,0 +1,27 @@
<template>
<div v-if="loading" class="m-state" role="status"><n-spin size="small" /><span>加载中</span></div>
<div v-else-if="error" class="m-state m-state--error">
<n-icon :component="CloudOfflineOutline" />
<p>{{ error }}</p>
<button class="m-link-btn" @click="$emit('retry')">重新加载</button>
</div>
<div v-else-if="empty" class="m-state">
<n-icon :component="FileTrayOutline" />
<p>{{ emptyText }}</p>
</div>
<slot v-else></slot>
</template>
<script setup lang="ts">
import { CloudOfflineOutline, FileTrayOutline } from '@vicons/ionicons5';
withDefaults(
defineProps<{ loading?: boolean; error?: string; empty?: boolean; emptyText?: string }>(),
{
loading: false,
error: '',
empty: false,
emptyText: '暂无数据',
}
);
defineEmits<{ retry: [] }>();
</script>
+15
View File
@@ -0,0 +1,15 @@
<template>
<header class="m-page-header">
<button v-if="back" class="m-icon-btn" aria-label="返回" @click="$router.back()">
<img :src="mobileAssets.back" alt="" />
</button>
<span v-else class="m-header-space"></span>
<h1>{{ title }}</h1>
<slot name="action"><span class="m-header-space"></span></slot>
</header>
</template>
<script setup lang="ts">
import { mobileAssets } from '../assets';
withDefaults(defineProps<{ title: string; back?: boolean }>(), { back: true });
</script>
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
<template>
<section
class="m-page m-agency-page"
:style="{ backgroundImage: `url(${mobileAssets.agencyBackground})` }"
>
<PageHeader title="" />
<img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" />
<section class="m-agency-block">
<h2><i>1</i>什么是平台代理?</h2>
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
</section>
<section class="m-agency-block">
<h2><i>2</i>代理奖励规则</h2>
<div class="m-agency-rewards">
<article v-for="item in rewards" :key="item.level" :class="item.className">
<img :src="item.icon" alt="" />
<div><b>{{ item.level }}</b><strong>{{ item.rate }}</strong></div>
<p>按该层用户<br />每日佣金计算</p>
</article>
</div>
</section>
<section class="m-agency-block m-agency-relation">
<h2><i>3</i>代理关系示意图</h2>
<div class="m-agent-root">
<img :src="mobileAssets.agencyPersonPrimary" alt="" /><span>您(代理)</span>
</div>
<div class="m-agent-tree">
<div v-for="group in 3" :key="group" class="m-agent-branch">
<div class="m-agent-first">
<img :src="mobileAssets.agencyPersonPrimary" alt="" /><span>一级用户</span><b>奖励 16%</b>
</div>
<div class="m-agent-children">
<div v-for="child in 2" :key="child">
<img :src="mobileAssets.agencyPersonSecondary" alt="" /><span>二级</span><b>8%</b>
<i></i>
<img :src="mobileAssets.agencyPersonTertiary" alt="" /><strong>4%</strong>
</div>
</div>
</div>
</div>
<p class="m-agent-note">一级为您直接推荐的用户;二级、三级为其后续推荐用户。</p>
</section>
</section>
</template>
<script setup lang="ts">
import PageHeader from '../components/PageHeader.vue';
import { mobileAssets } from '../assets';
const rewards = [
{ level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary },
{ level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary },
{ level: '三级用户', rate: '4%', className: 'is-tertiary', icon: mobileAssets.agencyPersonTertiary },
];
</script>
+141
View File
@@ -0,0 +1,141 @@
<template>
<main class="m-app m-auth">
<section class="m-auth-hero" aria-hidden="true">
<div class="m-auth-mark"><span></span><span></span><span></span></div>
<p>你好</p>
<h1>欢迎你的到来</h1>
</section>
<section class="m-auth-panel">
<div class="m-auth-tabs" role="tablist">
<button :class="{ active: mode === 'login' }" @click="mode = 'login'">账号登录</button>
<button :class="{ active: mode === 'register' }" @click="mode = 'register'"
>注册账号</button
>
</div>
<form class="m-form" @submit.prevent="submit">
<label v-if="mode === 'register'" class="m-input-row">
<n-icon :component="PersonOutline" />
<input
v-model.trim="form.username"
autocomplete="username"
maxlength="10"
placeholder="请输入账户名"
/>
</label>
<label class="m-input-row">
<n-icon :component="PhonePortraitOutline" />
<input
v-model.trim="form.phone"
inputmode="numeric"
autocomplete="tel"
maxlength="11"
placeholder="请输入手机号"
/>
</label>
<label class="m-input-row">
<n-icon :component="LockClosedOutline" />
<input
v-model="form.password"
type="password"
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
placeholder="请输入密码"
/>
</label>
<label v-if="mode === 'register'" class="m-input-row">
<n-icon :component="MailOpenOutline" />
<input
v-model.trim="form.referral_code"
maxlength="32"
placeholder="请输入推荐码(选填)"
/>
</label>
<router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify"
>忘记密码?</router-link
>
<p v-if="error" class="m-form-error" role="alert">{{ error }}</p>
<button class="m-primary-btn" type="submit" :disabled="submitting">
{{ submitting ? '请稍候...' : mode === 'login' ? '登录' : '注册' }}
</button>
<button
v-if="mode === 'register'"
class="m-outline-btn"
type="button"
@click="mode = 'login'"
>已有账户,马上登录</button
>
</form>
</section>
<label class="m-auth-agreement">
<input v-model="accepted" type="checkbox" />
<span>{{ mode === 'login' ? '登录即代表我已阅读并同意' : '我已阅读并同意' }}</span>
<router-link to="/app/content/user_agreement">《用户协议》</router-link>
<span>和</span>
<router-link to="/app/content/privacy_policy">《隐私政策》</router-link>
</label>
</main>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
LockClosedOutline,
MailOpenOutline,
PersonOutline,
PhonePortraitOutline,
} from '@vicons/ionicons5';
import { errorText, request, saveSession } from '../api';
type AuthResponse = { token: string };
const route = useRoute();
const router = useRouter();
const mode = ref<'login' | 'register'>('login');
const accepted = ref(false);
const submitting = ref(false);
const error = ref('');
const form = reactive({ username: '', phone: '', password: '', referral_code: '' });
watch(mode, () => {
form.username = '';
form.phone = '';
form.password = '';
form.referral_code = '';
accepted.value = false;
error.value = '';
});
async function submit() {
error.value = '';
if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码');
if (form.password.length < 6) return void (error.value = '密码至少为 6 位');
if (!accepted.value) return void (error.value = '请先阅读并同意用户协议和隐私政策');
if (mode.value === 'register' && !/^[a-z0-9]{1,10}$/.test(form.username)) {
return void (error.value = '账户名须为 1 至 10 位小写字母或数字');
}
submitting.value = true;
try {
const data = await request<AuthResponse>({
url: `/v1/auth/${mode.value}`,
method: 'POST',
data:
mode.value === 'login'
? { phone: form.phone, password: form.password }
: { ...form, privacy_accepted: true },
});
saveSession(data.token);
const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/app/')
? route.query.redirect
: '/app/home';
await router.replace(redirect);
} catch (err) {
error.value = errorText(err);
} finally {
submitting.value = false;
}
}
</script>
+274
View File
@@ -0,0 +1,274 @@
<template>
<section class="m-claim2">
<AsyncState :loading="loading" :error="error" @retry="load">
<div class="m-claim2-hero">
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
<button
type="button"
:disabled="claiming || !halls.length || eligibility?.eligible === false"
aria-label="自动领取任务"
@click="claim"
>
<img :src="mobileAssets.claimAutoButton" alt="自动领取" />
</button>
</div>
<div class="m-claim2-content">
<section class="m-claim2-stats">
<div v-for="item in statCards" :key="item.label">
<img :src="item.icon" alt="" />
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</section>
<div v-if="eligibility?.eligible === false" class="m-claim2-warning">
<n-icon :component="AlertCircleOutline" />
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
</div>
<section class="m-claim2-rules">
<h2><span></span>活动规则<span></span></h2>
<ol>
<li>平台运营时间每天 {{ businessHours }}。</li>
<li>每一次储入请先咨询客服获取商家卡号,如有疑问请联系在线客服。</li>
<li>领取后系统将自动分配任务,请按订单要求完成任务内容。</li>
<li>领取时间根据会员等级不同,系统随机匹配订单。</li>
<li>账户可用余额需达到 {{ money(eligibility?.minimum_amount) }} 才可领取。</li>
<li>订单需要在规定时间内完成,请勿超时提交。</li>
</ol>
</section>
</div>
</AsyncState>
<n-modal v-model:show="showResult" :mask-closable="false">
<section class="m-claim-result" :class="`is-${resultState}`">
<template v-if="resultState === 'matching'">
<h2>正在匹配任务</h2>
<p>{{ matchingHint }}</p>
<div class="m-match-visual">
<img class="orbit" :src="mobileAssets.claimSpinner" alt="" />
<img class="gift" :src="mobileAssets.claimMatchingGift" alt="" />
</div>
<div class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
<strong class="m-match-percent">{{ matchProgress }}%</strong>
<ol class="m-match-steps">
<li v-for="(step, index) in matchSteps" :key="step" :class="{ active: index <= matchStep }">
<span>✓</span>{{ step }}
</li>
</ol>
<button class="m-result-secondary" @click="openOrders">后台匹配,去记录查看</button>
</template>
<template v-else>
<img class="m-result-icon" :src="resultIcon" alt="" />
<h2>{{ resultTitle }}</h2>
<p>{{ resultDescription }}</p>
<div v-if="order?.product?.name" class="m-result-product">
<div>
<img v-if="safeImage(order.product.cover_url)" :src="safeImage(order.product.cover_url)" alt="" />
<n-icon v-else :component="ImageOutline" />
</div>
<section><small>任务商品</small><b>{{ order.product.name }}</b><strong>{{ money(order.total_amount, order.currency) }}</strong></section>
</div>
<dl v-if="resultState === 'success'" class="m-result-details">
<div><dt>预计佣金</dt><dd>{{ money(order?.commission_amount, order?.currency) }}</dd></div>
<div><dt>佣金比例</dt><dd>{{ commissionRate }}</dd></div>
<div><dt>任务编号</dt><dd>{{ order?.order_no || '--' }}</dd></div>
<div><dt>任务时间</dt><dd>{{ dateTime(order?.created_at) }}</dd></div>
</dl>
<div v-if="resultState === 'failed'" class="m-result-reason">
<b>失败原因</b><span>{{ failureMessage }}</span>
</div>
<div class="m-result-actions">
<button v-if="resultState !== 'success'" class="m-result-secondary" @click="retryClaim">重新领取</button>
<button class="m-result-primary" @click="primaryResultAction">{{ resultState === 'success' ? '查看记录' : '返回首页' }}</button>
</div>
</template>
</section>
</n-modal>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, money, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
const router = useRouter();
const loading = ref(true);
const claiming = ref(false);
const error = ref('');
const halls = ref<any[]>([]);
const stats = ref<any>();
const eligibility = ref<any>();
const order = ref<any>();
const showResult = ref(false);
const resultState = ref<ResultState>('matching');
const failureMessage = ref('当前任务库存未分配成功,请稍后重试');
const matchProgress = ref(18);
const pollFinished = ref(false);
let pollTimer: number | undefined;
let progressTimer: number | undefined;
let pollCount = 0;
const statCards = computed(() => [
{ label: '总资产', value: money(stats.value?.total_asset_amount), icon: mobileAssets.claimTotal },
{ label: '昨日佣金', value: money(stats.value?.yesterday_commission_amount), icon: mobileAssets.claimYesterday },
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
]);
const businessHours = computed(() => {
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5);
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5);
return `${start}-${end}`;
});
const matchStep = computed(() => (matchProgress.value < 35 ? 0 : matchProgress.value < 60 ? 1 : matchProgress.value < 85 ? 2 : 3));
const matchSteps = ['正在匹配任务', '获取商品信息', '确认任务内容', '发放任务结果'];
const matchingHint = computed(() => pollFinished.value ? '任务仍在匹配,可稍后前往记录查看' : '系统正在为您匹配任务,请稍等...');
const resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled);
const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务');
const resultDescription = computed(() => resultState.value === 'success' ? '恭喜你成功领取任务' : resultState.value === 'failed' ? '本次任务领取未成功,请稍后再试' : '本次任务已取消,可重新领取');
const commissionRate = computed(() => {
const total = Number(order.value?.total_amount || 0);
return total > 0 ? `${((Number(order.value?.commission_amount || 0) / total) * 100).toFixed(2)}%` : '--';
});
async function load() {
loading.value = true;
error.value = '';
try {
const [hallData, statData] = await Promise.all([
request<any[]>({ url: '/v1/task-halls' }),
request<any>({ url: '/v1/tasks/stats' }),
]);
halls.value = hallData || [];
stats.value = statData;
if (halls.value.length) await loadEligibility(halls.value[0].id);
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
async function loadEligibility(hallID: number) {
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } });
}
async function claim() {
if (claiming.value || !halls.value.length) return;
claiming.value = true;
pollFinished.value = false;
try {
await loadEligibility(halls.value[0].id);
if (!eligibility.value?.eligible) {
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
return;
}
openMatching();
order.value = await request<any>({
url: '/v1/task-orders/claim',
method: 'POST',
data: { hall_id: halls.value[0].id },
});
handleOrder(order.value);
} catch (err) {
failureMessage.value = errorText(err);
resultState.value = 'failed';
showResult.value = true;
stopPolling();
} finally {
claiming.value = false;
}
}
function openMatching() {
stopPolling();
resultState.value = 'matching';
showResult.value = true;
matchProgress.value = 18;
pollCount = 0;
progressTimer = window.setInterval(() => {
matchProgress.value = Math.min(88, matchProgress.value + Math.ceil(Math.random() * 5));
}, 700);
}
function handleOrder(value: any) {
order.value = value;
if (value?.status === 'matching') {
schedulePoll();
} else if (['pending', 'processing', 'completed'].includes(value?.status)) {
matchProgress.value = 100;
resultState.value = 'success';
stopPolling();
} else if (value?.status === 'cancelled') {
resultState.value = 'cancelled';
stopPolling();
} else {
failureMessage.value = value?.message || failureMessage.value;
resultState.value = 'failed';
stopPolling();
}
}
function schedulePoll() {
if (!order.value?.id) return;
pollTimer = window.setTimeout(async () => {
pollCount += 1;
try {
handleOrder(await request({ url: `/v1/task-orders/${order.value.id}` }));
} catch (err) {
if (pollCount >= 10) {
pollFinished.value = true;
stopPolling();
} else schedulePoll();
}
if (resultState.value === 'matching' && pollCount >= 10) {
pollFinished.value = true;
stopPolling();
}
}, 1200);
}
function stopPolling(clearProgress = true) {
if (pollTimer) window.clearTimeout(pollTimer);
if (clearProgress && progressTimer) window.clearInterval(progressTimer);
pollTimer = undefined;
if (clearProgress) progressTimer = undefined;
}
function closeResult() {
showResult.value = false;
stopPolling();
}
function openOrders() {
closeResult();
router.push('/app/orders');
}
function openPendingOrder() {
const orderID = Number(eligibility.value?.pending_order_id || 0);
router.push(orderID > 0 ? `/app/orders/${orderID}` : '/app/orders');
}
function retryClaim() {
closeResult();
void claim();
}
function primaryResultAction() {
closeResult();
router.push(resultState.value === 'success' ? `/app/orders/${order.value?.id}` : '/app/home');
}
onMounted(load);
onBeforeUnmount(() => stopPolling());
</script>
@@ -0,0 +1,69 @@
<template>
<section class="m-page m-qualification">
<PageHeader :title="content?.title || '公司资质'" />
<AsyncState :loading="loading">
<div class="m-qualification-card">
<header><span>01</span><h2>平台经营理念</h2></header>
<strong>“多实惠,多乐趣”</strong>
<p>{{ operationText }}</p>
<img :src="mobileAssets.qualificationOperation" alt="平台经营理念" />
</div>
<div class="m-qualification-card">
<header><span>02</span><h2>平台发展理念</h2></header>
<strong>“新国潮,新消费”</strong>
<p>{{ developmentText }}</p>
<img :src="mobileAssets.qualificationDevelopment" alt="平台发展理念" />
</div>
<section class="m-qualification-brands">
<header><span>03</span><h2>合作品牌</h2></header>
<div>
<b>HUAWEI<small>华为</small></b><b class="iqiyi">iQIYI<small>爱奇艺</small></b><b class="tencent">腾讯视频</b>
<b class="midea">Midea<small>美的</small></b><b class="dly">达利园</b><b class="supor">SUPOR<small>苏泊尔</small></b>
<b class="ctf">周大福</b><b class="xtep">X 特步</b><b class="anta">ANTA</b>
</div>
</section>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, request } from '../api';
import { mobileAssets } from '../assets';
interface ContentData {
title: string;
summary: string;
body_text: string;
}
const content = ref<ContentData>();
const loading = ref(true);
const error = ref('');
const fallback = '我们致力于通过高效的供应链和优质的服务为用户带来更多实惠与更美好的生活乐趣,让品质好物走进更多人的生活。';
const paragraphs = computed(() =>
String(content.value?.body_text || '').split(/\n+/).map((item) => item.trim()).filter(Boolean),
);
const operationText = computed(() => content.value?.summary || paragraphs.value[0] || fallback);
const developmentText = computed(() => paragraphs.value[1] || paragraphs.value[0] || fallback);
async function load() {
loading.value = true;
error.value = '';
try {
content.value = await request<ContentData>({ url: '/v1/contents/company_qualification' });
} catch (err) {
// The page remains usable with the approved copy while content is being configured.
console.warn(errorText(err));
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
+120
View File
@@ -0,0 +1,120 @@
<template>
<section class="m-page m-page--white m-finance-records">
<PageHeader title="记录" />
<div class="m-finance-tabs" role="tablist">
<button
v-for="item in tabs"
:key="item.value"
:class="{ active: tab === item.value }"
@click="changeTab(item.value)"
>
{{ item.label }}
</button>
</div>
<AsyncState
:loading="loading"
:error="error"
:empty="!visibleRecords.length"
empty-text="暂无相关记录"
@retry="load"
>
<div class="m-finance-list">
<button v-for="item in visibleRecords" :key="`${item.type}-${item.id}`" type="button">
<span class="m-finance-icon" :class="`is-${item.type}`">
<img
:src="item.type === 'recharge' ? mobileAssets.rechargeRecord : mobileAssets.withdrawalRecord"
alt=""
/>
</span>
<span class="m-finance-main">
<b>{{ item.no }}</b>
<time>{{ dateTime(item.created_at) }}</time>
</span>
<span class="m-finance-result">
<b>{{ item.type === 'recharge' ? '+' : '-' }}{{ plainMoney(item.amount) }}</b>
<em :class="item.success ? 'is-success' : 'is-failed'">{{ item.status_text }}</em>
</span>
</button>
</div>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, request } from '../api';
import { mobileAssets } from '../assets';
type RecordType = 'all' | 'recharge' | 'withdrawal';
const route = useRoute();
const router = useRouter();
const tabs: { label: string; value: RecordType }[] = [
{ label: '全部', value: 'all' },
{ label: '储入记录', value: 'recharge' },
{ label: '取出记录', value: 'withdrawal' },
];
const requestedTab = String(route.query.tab || 'all');
const tab = ref<RecordType>(
requestedTab === 'recharge' || requestedTab === 'withdrawal' ? requestedTab : 'all'
);
const loading = ref(true);
const error = ref('');
const records = ref<any[]>([]);
const visibleRecords = computed(() =>
tab.value === 'all' ? records.value : records.value.filter((item) => item.type === tab.value)
);
const successful = new Set(['succeeded', 'completed', 'paid']);
const plainMoney = (value: number) => (Number(value || 0) / 100).toFixed(2);
function normalize(item: any, type: 'recharge' | 'withdrawal') {
const success = successful.has(item.status);
return {
id: item.id,
type,
no: item.recharge_no || item.withdrawal_no || `SW${item.id}`,
amount: item.request_amount || item.amount || 0,
created_at: item.created_at,
status_text:
item.status_text || `${type === 'recharge' ? '储入' : '取出'}${success ? '成功' : '失败'}`,
success,
};
}
async function load() {
loading.value = true;
error.value = '';
try {
const [rechargeResult, withdrawalResult] = await Promise.allSettled([
request<any>({
url: '/v1/recharge-orders',
params: { page: 1, page_size: 50, status: 'all' },
}),
request<any>({
url: '/v1/withdrawal-orders',
params: { page: 1, page_size: 50, status: 'all' },
}),
]);
if (rechargeResult.status === 'rejected' && withdrawalResult.status === 'rejected') {
throw withdrawalResult.reason;
}
const recharge = rechargeResult.status === 'fulfilled' ? rechargeResult.value.items || [] : [];
const withdrawals =
withdrawalResult.status === 'fulfilled' ? withdrawalResult.value.items || [] : [];
records.value = [
...recharge.map((item: any) => normalize(item, 'recharge')),
...withdrawals.map((item: any) => normalize(item, 'withdrawal')),
].sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
async function changeTab(value: RecordType) {
tab.value = value;
await router.replace({ query: value === 'all' ? {} : { tab: value } });
}
onMounted(load);
</script>
+382
View File
@@ -0,0 +1,382 @@
<template>
<section
class="m-page m-page--white m-subpage"
:class="{ 'm-subpage--profile': formType === 'profile' }"
:style="
formType === 'profile'
? { backgroundImage: `url(${mobileAssets.profileBackground})` }
: undefined
"
>
<PageHeader :title="title" />
<AsyncState
v-if="formType === 'profile'"
:loading="loading"
:error="error"
@retry="loadProfile"
>
<div class="m-profile-edit">
<div class="m-avatar m-avatar--large"
><img
v-if="safeImage(me?.avatar_url)"
:src="safeImage(me.avatar_url)"
alt="用户头像" /><n-icon v-else :component="PersonCircleOutline" /><img
class="m-avatar-edit"
:src="mobileAssets.avatarEdit"
alt=""
/></div>
<div class="m-info-card m-card">
<div
><span>昵称</span><b>{{ me?.username }}</b></div
><div
><span>手机号码</span><b>{{ me?.phone }}</b></div
><div
><span>邀请码</span><b>{{ me?.invite_code }}</b></div
>
<router-link to="/app/profile/password"
><span>密码管理</span><n-icon :component="ChevronForwardOutline"
/></router-link>
<router-link to="/app/profile/trade-password"
><span>交易密码管理</span><n-icon :component="ChevronForwardOutline"
/></router-link>
</div>
</div>
</AsyncState>
<form v-else-if="formType === 'bank'" class="m-plain-form" @submit.prevent="saveBank">
<label
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
/></label>
<label
><span>银行卡号</span
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号"
/></label>
<label
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
/></label>
<label
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC"
/></label>
<label
><span>预留手机号</span
><input
v-model.trim="bank.reserved_phone"
inputmode="numeric"
maxlength="11"
placeholder="请输入预留手机号(选填)"
/></label>
<label
><span>交易密码</span
><input
v-model="bank.trade_password"
type="password"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位交易密码"
/></label>
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p>
<p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn" :disabled="submitting">{{
submitting ? '提交中...' : '确认绑定'
}}</button>
</form>
<form
v-else-if="formType === 'tradePassword'"
class="m-password-form"
@submit.prevent="saveTradePassword"
>
<h2>设置支付密码</h2><p>支付密码用于收款账户绑定和取出确认</p>
<div class="m-code-input"
><input
v-model="trade.new_trade_password"
type="password"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位数字"
/></div>
<label class="m-input-row"
><n-icon :component="LockClosedOutline" /><input
v-model="trade.login_password"
type="password"
placeholder="请输入登录密码确认"
/></label>
<label class="m-input-row"
><n-icon :component="KeyOutline" /><input
v-model="trade.old_trade_password"
type="password"
inputmode="numeric"
maxlength="6"
placeholder="已设置过时请输入原支付密码"
/></label>
<p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn" :disabled="submitting">{{
submitting ? '提交中...' : '确定'
}}</button>
</form>
<form
v-else-if="formType === 'loginPassword'"
class="m-password-form"
@submit.prevent="saveLoginPassword"
>
<h2>修改登录密码</h2><p>修改成功后需要重新登录</p>
<label class="m-input-row"
><n-icon :component="LockClosedOutline" /><input
v-model="loginPassword.old_password"
type="password"
autocomplete="current-password"
placeholder="请输入当前密码"
/></label>
<label class="m-input-row"
><n-icon :component="KeyOutline" /><input
v-model="loginPassword.new_password"
type="password"
autocomplete="new-password"
placeholder="请输入新密码"
/></label>
<label class="m-input-row"
><n-icon :component="KeyOutline" /><input
v-model="loginPassword.confirm_password"
type="password"
autocomplete="new-password"
placeholder="请再次输入新密码"
/></label>
<p v-if="error" class="m-form-error">{{ error }}</p>
<button class="m-primary-btn" :disabled="submitting">{{
submitting ? '提交中...' : '确定修改'
}}</button>
</form>
<form v-else-if="formType === 'verify'" class="m-plain-form m-verify" @submit.prevent="goReset">
<label
><span>手机号</span
><input
v-model.trim="verify.phone"
inputmode="numeric"
maxlength="11"
placeholder="请输入手机号"
/></label>
<label
><span>验证码</span
><input
v-model.trim="verify.code"
inputmode="numeric"
maxlength="6"
placeholder="请输入验证码"
/><button type="button" @click="notifyUnavailable">获取验证码</button></label
>
<p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn">确定</button>
</form>
<form
v-else-if="formType === 'reset'"
class="m-password-form"
@submit.prevent="notifyUnavailable"
>
<h2>重设密码</h2><p>请输入新的登录密码</p
><label class="m-input-row"
><n-icon :component="LockClosedOutline" /><input
v-model="resetPassword"
type="password"
placeholder="请输入新密码" /></label
><button class="m-primary-btn">确定</button>
</form>
<AsyncState
v-else
:loading="loading"
:error="error"
:empty="!content"
empty-text="暂无内容"
@retry="loadContent"
>
<article v-if="content" class="m-content"
><h1>{{ content.title }}</h1
><p class="m-content-summary">{{ content.summary }}</p
><div class="m-content-body">{{ content.body_text || '暂无正文' }}</div
><time v-if="content.published_at"
>发布时间:{{ dateTime(content.published_at) }}</time
></article
>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
ChevronForwardOutline,
KeyOutline,
LockClosedOutline,
PersonCircleOutline,
} from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { clearSession, dateTime, errorText, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
const route = useRoute();
const router = useRouter();
const formType = computed(() => String(route.meta.form || ''));
const contentTitles: Record<string, string> = {
recharge_records: '储入记录',
withdrawal_records: '取出记录',
wallet_ledgers: '账户明细',
notices: '弹窗明细',
};
const title = computed(
() =>
content.value?.title ||
contentTitles[String(route.params.key || '')] ||
String(route.meta.title || '')
);
const loading = ref(false);
const submitting = ref(false);
const error = ref('');
const me = ref<any>();
const content = ref<any>();
const resetPassword = ref('');
const bank = reactive({
account_type: 'bank_card',
bank_code: '',
bank_name: '',
holder_name: '',
account_no: '',
reserved_phone: '',
trade_password: '',
qr_attachment_id: 0,
});
const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' });
const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
const verify = reactive({ phone: '', code: '' });
async function loadProfile() {
loading.value = true;
error.value = '';
try {
me.value = await request({ url: '/v1/me' });
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
async function saveBank() {
error.value = '';
if (
!bank.holder_name ||
!/^[0-9 -]{8,36}$/.test(bank.account_no) ||
!bank.bank_name ||
!/^[0-9]{6}$/.test(bank.trade_password)
) {
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码';
return;
}
submitting.value = true;
try {
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank });
window.$message?.success('提交成功,等待后台核验');
router.back();
} catch (err) {
error.value = errorText(err);
} finally {
submitting.value = false;
}
}
async function saveTradePassword() {
error.value = '';
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
error.value = '请输入 6 位支付密码并填写登录密码';
return;
}
submitting.value = true;
try {
await request({
url: '/v1/me/trade-password',
method: 'PUT',
data: { ...trade, old_trade_password: trade.old_trade_password || undefined },
});
window.$message?.success('支付密码设置成功');
router.back();
} catch (err) {
error.value = errorText(err);
} finally {
submitting.value = false;
}
}
async function saveLoginPassword() {
error.value = '';
if (loginPassword.old_password.length < 6 || loginPassword.new_password.length < 6) {
error.value = '登录密码至少为 6 位';
return;
}
if (loginPassword.new_password !== loginPassword.confirm_password) {
error.value = '两次输入的新密码不一致';
return;
}
submitting.value = true;
try {
await request({
url: '/v1/me/password',
method: 'PUT',
data: {
old_password: loginPassword.old_password,
new_password: loginPassword.new_password,
},
});
clearSession();
window.$message?.success('密码修改成功,请重新登录');
await router.replace('/app/auth');
} catch (err) {
error.value = errorText(err);
} finally {
submitting.value = false;
}
}
function notifyUnavailable() {
window.$message?.warning('短信验证接口尚未开放');
}
function goReset() {
if (!/^1\d{10}$/.test(verify.phone) || !/^\d{4,6}$/.test(verify.code)) {
error.value = '请输入正确的手机号和验证码';
return;
}
notifyUnavailable();
}
async function loadContent() {
loading.value = true;
error.value = '';
const key = String(route.params.key || '');
if (['recharge_records', 'wallet_ledgers', 'notices'].includes(key)) {
loading.value = false;
return;
}
try {
if (key === 'withdrawal_records') {
const data = await request<any>({
url: '/v1/withdrawal-orders',
params: { page: 1, page_size: 20, status: 'all' },
});
content.value = {
title: '取出记录',
body_text: data.items?.length
? data.items
.map((x: any) => `${dateTime(x.created_at)} ${x.status_text || x.status}`)
.join('\n')
: '暂无取出记录',
};
} else {
content.value = await request({ url: `/v1/contents/${key}` });
}
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
onMounted(() => {
if (formType.value === 'profile') loadProfile();
else if (formType.value === 'content') loadContent();
});
</script>
+182
View File
@@ -0,0 +1,182 @@
<template>
<section class="m-home">
<div class="m-home-hero" :style="bannerStyle">
<label class="m-search">
<n-icon :component="SearchOutline" />
<input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" />
</label>
<div class="m-greeting">
<h1>Hi,{{ home?.profile?.username || '会员' }}</h1>
<p>一起创造最美好的生活</p>
</div>
<div class="m-balance-card">
<div
><span>我的积分账户</span
><strong>{{ money(home?.wallet?.available_amount, home?.wallet?.currency) }}</strong></div
>
<div class="m-coin" aria-hidden="true">¥</div>
</div>
</div>
<div class="m-home-body">
<AsyncState :loading="loading" :error="error" @retry="load">
<div class="m-shortcuts m-card">
<button
v-for="item in shortcuts"
:key="item.label"
:disabled="!item.enabled"
@click="openShortcut(item)"
>
<span><img :src="item.icon" alt="" /></span><b>{{ item.label }}</b>
</button>
</div>
<div v-if="home?.notices?.length" class="m-notice m-card">
<n-icon :component="MegaphoneOutline" />
<p>{{ home.notices[0].title || home.notices[0].content }}</p>
</div>
<section class="m-section m-card m-home-ranking">
<div class="m-section-title"
><h2>荣誉榜</h2><router-link to="/app/leaderboard">更多 ›</router-link></div
>
<div v-if="leaderboard.length" class="m-home-rank-list">
<router-link
v-for="entry in leaderboard.slice(0, 4)"
:key="entry.rank"
to="/app/leaderboard"
>
<b :class="`rank-${entry.rank}`">{{ entry.rank === 1 ? '♛' : entry.rank }}</b>
<span class="m-home-rank-avatar">
<img v-if="safeImage(entry.avatar_url)" :src="safeImage(entry.avatar_url)" alt="" />
<i v-else>{{ entry.username.slice(0, 1) }}</i>
</span>
<strong>{{ entry.username }}</strong>
<em>{{ formatCount(entry.completed_count) }}</em>
<small>{{ entry.status || '领取成功' }}</small>
</router-link>
</div>
<p v-else class="m-home-rank-empty">暂无排行数据</p>
</section>
<section class="m-section m-card">
<div class="m-section-title"><h2>公司简介</h2></div>
<div class="m-company-links">
<router-link
v-for="item in companyLinks"
:key="item.label"
:to="item.path"
>
<img :src="item.icon" alt="" /><span>{{ item.label }}</span>
</router-link>
</div>
</section>
</AsyncState>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue';
import { errorText, money, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
interface HomeData {
profile: { username: string; avatar_url: string; level_name: string };
wallet: { available_amount: number; frozen_amount: number; currency: string };
features: Record<string, boolean>;
banners: Array<{ image_url?: string; title?: string }>;
notices: Array<{ title?: string; content?: string }>;
task_summary: {
today_count: number;
today_commission_amount: number;
yesterday_commission_amount: number;
};
}
interface LeaderboardEntry {
rank: number;
username: string;
avatar_url: string;
completed_count: number;
status: string;
}
const router = useRouter();
const home = ref<HomeData>();
const loading = ref(true);
const error = ref('');
const leaderboard = ref<LeaderboardEntry[]>([]);
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
const bannerStyle = computed(() => {
const url = safeImage(home.value?.banners?.[0]?.image_url);
return {
backgroundImage: url
? `linear-gradient(180deg, rgba(16,13,8,.18), rgba(16,13,8,.54)), url(${url})`
: `url(${mobileAssets.homeHero})`,
};
});
const shortcuts = computed(() => [
{
label: '领取',
path: '/app/claim',
icon: mobileAssets.shortcutClaim,
enabled: home.value?.features?.task_enabled !== false,
},
{
label: '储入',
path: '/app/recharge',
icon: mobileAssets.shortcutRecharge,
enabled: home.value?.features?.recharge_enabled !== false,
},
{
label: '取出',
path: '/app/profile/withdrawal',
icon: mobileAssets.shortcutWithdraw,
enabled: home.value?.features?.withdrawal_enabled !== false,
},
{
label: '商城',
path: '/app/mall',
icon: mobileAssets.shortcutMall,
enabled: home.value?.features?.manual_mall_enabled !== false,
},
]);
const companyLinks = [
{
label: '平台介绍',
path: '/app/content/platform_intro',
icon: mobileAssets.companyPlatform,
},
{ label: '规则说明', path: '/app/rules', icon: mobileAssets.companyRules },
{
label: '公司资质',
path: '/app/qualification',
icon: mobileAssets.companyQualification,
},
{ label: '代理合作', path: '/app/agency', icon: mobileAssets.companyAgency },
];
function openShortcut(item: { enabled: boolean; path: string }) {
if (item.enabled) router.push(item.path);
else window.$message?.warning('该功能暂未开放');
}
async function load() {
loading.value = true;
error.value = '';
try {
const [homeData, rankData] = await Promise.all([
request<HomeData>({ url: '/v1/home' }),
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
]);
home.value = homeData;
leaderboard.value = rankData || [];
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
+86
View File
@@ -0,0 +1,86 @@
<template>
<section class="m-leaderboard" :style="{ backgroundImage: `url(${mobileAssets.rankBackground})` }">
<PageHeader title="荣誉榜" />
<AsyncState
:loading="loading"
:error="error"
:empty="!entries.length"
empty-text="暂无排行数据"
@retry="load"
>
<div class="m-rank-podium" aria-label="前三名">
<article
v-for="entry in podium"
:key="entry.rank"
class="m-rank-winner"
:class="`rank-${entry.rank}`"
:style="{ backgroundImage: `url(${podiumBackground(entry.rank)})` }"
>
<div class="m-rank-avatar">
<img v-if="safeImage(entry.avatar_url)" :src="safeImage(entry.avatar_url)" alt="" />
<span v-else>{{ entry.username.slice(0, 1) }}</span>
</div>
<b>{{ entry.rank }}</b>
<strong>{{ entry.username }}</strong>
<em>{{ number(entry.completed_count) }}</em>
<small>{{ entry.status }}</small>
</article>
</div>
<section v-if="remaining.length" class="m-rank-table">
<header><span>排名</span><span>用户昵称</span><span>领取数量</span><span>状态</span></header>
<div v-for="entry in remaining" :key="entry.rank" class="m-rank-row">
<b>{{ entry.rank }}</b>
<div class="m-rank-user">
<span class="m-rank-user-avatar">
<img v-if="safeImage(entry.avatar_url)" :src="safeImage(entry.avatar_url)" alt="" />
<i v-else>{{ entry.username.slice(0, 1) }}</i>
</span>
<span>{{ entry.username }}</span>
</div>
<strong>{{ number(entry.completed_count) }}</strong>
<em>{{ entry.status.replace('成功', '') }}</em>
</div>
</section>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
interface LeaderboardEntry {
rank: number;
username: string;
avatar_url: string;
completed_count: number;
status: string;
}
const entries = ref<LeaderboardEntry[]>([]);
const loading = ref(true);
const error = ref('');
const podium = computed(() => [entries.value[2], entries.value[0], entries.value[1]].filter(Boolean));
const remaining = computed(() => entries.value.slice(3));
const number = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
const podiumBackground = (rank: number) =>
rank === 1 ? mobileAssets.rankFirst : rank === 2 ? mobileAssets.rankSecond : mobileAssets.rankThird;
async function load() {
loading.value = true;
error.value = '';
try {
entries.value = (await request<LeaderboardEntry[]>({ url: '/v1/leaderboard' })) || [];
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
onMounted(load);
</script>

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