File size: 10,878 Bytes
541ad39
bbee108
 
cb1ecf1
541ad39
cb1ecf1
 
ca323ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbee108
ca323ef
bbee108
cb1ecf1
50dedd0
bbee108
cb1ecf1
ca323ef
50dedd0
 
bbee108
 
ca323ef
 
50dedd0
ca323ef
 
 
541ad39
 
ca323ef
 
 
541ad39
ca323ef
bbee108
ca323ef
541ad39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbee108
 
 
541ad39
cb1ecf1
 
541ad39
ca323ef
 
 
 
 
 
 
cb1ecf1
541ad39
 
 
 
 
 
 
bbee108
 
 
541ad39
bbee108
cb1ecf1
541ad39
bbee108
 
cb1ecf1
541ad39
ca323ef
 
 
 
 
 
 
bbee108
 
cb1ecf1
ca323ef
cb1ecf1
 
 
 
ca323ef
 
 
 
bbee108
cb1ecf1
 
bbee108
541ad39
 
 
 
cb1ecf1
 
541ad39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbee108
 
ca323ef
bbee108
 
541ad39
 
 
 
 
 
 
50dedd0
 
541ad39
 
 
 
50dedd0
541ad39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50dedd0
541ad39
50dedd0
541ad39
 
50dedd0
 
541ad39
ca323ef
541ad39
50dedd0
bbee108
 
541ad39
50dedd0
e1e9747
bbee108
541ad39
 
e1e9747
bbee108
cb1ecf1
e1e9747
bbee108
541ad39
 
e1e9747
ca323ef
 
 
 
541ad39
 
 
 
bbee108
541ad39
ca323ef
 
bbee108
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
import os, json, random, datetime
import gradio as gr

APP_TITLE = "100个不"
TASKS_FILE = "tasks.json"
TARGET_PERCENT = 100

LEVEL_LABELS = {"easy": "简单", "medium": "进阶", "hard": "挑战"}
LABEL_TO_KEY = {v: k for k, v in LEVEL_LABELS.items()}
LEVEL_REWARDS = {"easy": 1, "medium": 5, "hard": 10}

# ====== 读取 tasks.json ======
def load_tasks_from_file():
    if not os.path.exists(TASKS_FILE):
        return {"easy": [], "medium": [], "hard": []}, f"未找到 {TASKS_FILE},使用空题库。"
    try:
        with open(TASKS_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
        for k in ["easy", "medium", "hard"]:
            if k not in data or not isinstance(data[k], list):
                raise ValueError("JSON 结构不正确,应包含 easy/medium/hard 三个数组。")
        return data, None
    except Exception as e:
        return {"easy": [], "medium": [], "hard": []}, f"{TASKS_FILE} 解析失败:{e}。已使用空题库。"

def init_state():
    tasks, msg = load_tasks_from_file()
    return {
        "done_percent": 0,
        "history": [],
        "current": None,
        "current_level": None,
        "tasks": tasks,
        "load_msg": msg,
        "celebrated": False
    }

def progress_text(state):
    pct = min(100, int(state["done_percent"]))
    bar = f"{'█' * (pct//5)}{'░' * (20 - pct//5)}"
    return f"进度:{pct}%\n{bar}"

def draw_task(state, level_cn):
    if state["done_percent"] >= TARGET_PERCENT:
        return state, "🎉 你已达成 100%,可以重置开始新循环或导出记录。", progress_text(state), state.get("load_msg") or "", ""
    level = LABEL_TO_KEY.get(level_cn, "easy")
    pool = state["tasks"].get(level, [])
    if not pool:
        return state, f"({LEVEL_LABELS[level]}暂无任务,请检查 {TASKS_FILE})", progress_text(state), state.get("load_msg") or "", ""
    task = random.choice(pool)
    state["current"] = task
    state["current_level"] = level
    return state, f"难度:{LEVEL_LABELS[level]}\n\n任务:{task}", progress_text(state), "", ""

def _celebration_overlay():
    return """
    <div class="cele-overlay" onclick="this.remove()">
      <div class="cele-modal" onclick="event.stopPropagation()">
        <div class="cele-title">🎉 恭喜完成 100 个不!</div>
        <div class="cele-sub">你已经把“拒绝”练成了勇气。去对世界说 YES 吧!</div>
        <div class="confetti">
          <span></span><span></span><span></span><span></span><span></span>
          <span></span><span></span><span></span><span></span><span></span>
          <span></span><span></span><span></span><span></span><span></span>
        </div>
        <button class="cele-btn" onclick="document.querySelector('.cele-overlay')?.remove()">关闭</button>
      </div>
    </div>
    """

def complete_task(state, note):
    if not state["current"]:
        return state, "(请先抽取任务)", progress_text(state), ""
    level = state["current_level"]
    reward = LEVEL_REWARDS.get(level, 0)
    before = state["done_percent"]
    state["done_percent"] = min(TARGET_PERCENT, state["done_percent"] + reward)
    state["history"].append((
        state["current"], level,
        datetime.datetime.now().isoformat(timespec="seconds"),
        (note or "").strip()
    ))
    msg = f"✅ 已完成:{state['current']}(+{reward}%)"
    state["current"], state["current_level"] = None, None

    cele_html = ""
    if before < TARGET_PERCENT and state["done_percent"] >= TARGET_PERCENT and not state["celebrated"]:
        state["celebrated"] = True
        cele_html = _celebration_overlay()

    return state, msg, progress_text(state), cele_html

def skip_task(state):
    if not state["current"]:
        return state, "(当前没有任务)", progress_text(state), ""
    skipped = state["current"]
    state["current"], state["current_level"] = None, None
    return state, f"⏭ 已跳过:{skipped}", progress_text(state), ""

def reset_state():
    st = init_state()
    return st, "已重置进度。", progress_text(st), (st["load_msg"] or ""), ""

def reload_tasks(state):
    tasks, msg = load_tasks_from_file()
    state["tasks"] = tasks
    state["current"], state["current_level"] = None, None
    tip = msg if msg else "已从 tasks.json 重新加载题库。"
    return state, tip

def export_progress(state):
    lines = [
        "# 100个不|练习记录",
        f"已完成:{state['done_percent']}%",
        ""
    ]
    for i, (task, lvl, ts, note) in enumerate(state["history"], 1):
        note_part = f" 反思:{note}" if note else ""
        lines.append(f"{i}. [{ts}] {LEVEL_LABELS[lvl]}任务:{task}{note_part}")
    content = "\n".join(lines) if len(lines) > 3 else "暂无记录"
    return gr.File.update(value=("progress.txt", content))

theme = gr.themes.Soft(primary_hue="pink", secondary_hue="cyan", neutral_hue="slate")
CSS = """
body,.gradio-container{background:#faf7fb!important;}
.topbar{display:flex;align-items:center;gap:8px;padding:28px 16px;background:linear-gradient(90deg,#ffe8f3,#e8fbff);
       border:1px solid #eddcf3;border-radius:16px;margin-bottom:12px;}
.title{font-weight:900;font-size:28px;color:#111;}
.card{background:#fff;border:1px solid #eddcf3;border-radius:14px;box-shadow:0 4px 18px rgba(0,0,0,.05);padding:16px;}
.taskbox{border:1px dashed #eddcf3;border-radius:10px;padding:12px;font-weight:700;}
.tip{color:#6b7280;font-size:12px;}
.hero{display:flex;flex-direction:column;gap:12px;align-items:flex-start;padding:24px;}
.hero h1{margin:0;font-size:32px}
.hero p{margin:0;color:#374151}
.hero .cta{padding:10px 16px;border-radius:10px;border:1px solid #eddcf3;background:#fff;cursor:pointer;font-weight:700}
.hero .cta:hover{border-color:#ff4fa3;color:#ff4fa3}
.cele-overlay{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:9999;}
.cele-modal{width:min(560px,92vw);background:#fff;border:1px solid #eddcf3;border-radius:16px;
            box-shadow:0 20px 60px rgba(0,0,0,.18);padding:20px 18px 16px;text-align:center;position:relative;overflow:hidden;}
.cele-title{font-size:22px;font-weight:900;margin-bottom:6px;}
.cele-sub{color:#374151;margin-bottom:12px;}
.cele-btn{padding:10px 14px;border-radius:10px;border:1px solid #eddcf3;background:#fff;cursor:pointer;font-weight:700;}
.cele-btn:hover{border-color:#ff4fa3;color:#ff4fa3}
.confetti{position:absolute;inset:0;pointer-events:none;}
.confetti span{position:absolute;top:-10px;width:10px;height:14px;border-radius:2px;animation:fall 1.8s linear infinite;opacity:.9}
.confetti span:nth-child(odd){background:#ff4fa3}
.confetti span:nth-child(even){background:#5ee0ff}
@keyframes fall{
  0%{transform:translateY(-20px) rotate(0deg)}
  100%{transform:translateY(520px) rotate(360deg)}
}
"""

with gr.Blocks(title=APP_TITLE, theme=theme, css=CSS) as demo:
    state = gr.State(init_state())

    # --- 首屏(Landing) ---
    with gr.Group(visible=True) as landing:
        gr.HTML("""
          <div class='topbar'><div class='title'>100个不</div></div>
        """)
        gr.HTML("""
          <div class="card hero">
            <h1>A gentle social challenge for shy souls.</h1>
            <p>Collect 100 “no’s,” build real courage, and discover the world isn’t that scary after all.</p>
          </div>
        """)
        start_btn = gr.Button("开始挑战 ▶️", variant="primary")

    # --- 主界面 ---
    with gr.Group(visible=False) as main:
        gr.HTML(f"""<div class='topbar'><div class='title'>{APP_TITLE}</div></div>""")

        with gr.Row():
            with gr.Column(scale=5):
                with gr.Group(elem_classes=["card"]):
                    gr.Markdown("#### 选择难度")
                    level_choice = gr.Radio(
                        label="难度",
                        choices=[LEVEL_LABELS["easy"], LEVEL_LABELS["medium"], LEVEL_LABELS["hard"]],
                        value=LEVEL_LABELS["easy"]
                    )
                    with gr.Row():
                        btn_draw = gr.Button("🎲 抽取任务", variant="primary")
                        btn_reload = gr.Button("🔁 刷新题库")
                    with gr.Row():
                        btn_complete = gr.Button("✅ 标记完成")
                        btn_skip = gr.Button("⏭ 跳过")
                    reflect = gr.Textbox(label="反思(可选)", placeholder="完成后写一句感受", lines=2)
                    with gr.Row():
                        btn_export = gr.Button("📤 导出记录")
                        btn_reset = gr.Button("🧹 重置进度")

            with gr.Column(scale=7):
                with gr.Group(elem_classes=["card"]):
                    gr.Markdown("#### 当前任务")
                    current_task = gr.Markdown("(点击 🎲 抽取任务)", elem_classes=["taskbox"])
                    progress = gr.Markdown("进度:0%\n░░░░░░░░░░░░░░░░░░░░░░")
                    tip = gr.Markdown("", elem_classes=["tip"])
                file_out = gr.File(label="下载导出记录", visible=True)
                cele = gr.HTML(value="", visible=True)

    # ====== 事件绑定 ======
    def on_start(st):
        first_tip = st.get("load_msg") or ""
        st["load_msg"] = ""
        return (gr.update(visible=False), gr.update(visible=True), first_tip, "")

    def on_draw(st, lvl_cn):
        st, msg, prog, info, cele_html = draw_task(st, lvl_cn)
        return st, msg, prog, info, cele_html

    def on_complete(st, note):
        st, msg, prog, cele_html = complete_task(st, note)
        return st, msg, prog, "", "", cele_html

    def on_skip(st):
        st, msg, prog, cele_html = skip_task(st)
        return st, msg, prog, "", cele_html

    def on_export(st):
        return export_progress(st)

    def on_reset():
        st, msg, prog, info, cele_html = reset_state()
        return st, "(点击 🎲 抽取任务)", prog, info, None, cele_html

    def on_reload(st):
        st, info = reload_tasks(st)
        return st, info

    start_btn.click(on_start, inputs=[state], outputs=[landing, main, tip, cele])
    btn_draw.click(on_draw, inputs=[state, level_choice], outputs=[state, current_task, progress, tip, cele])
    btn_complete.click(on_complete, inputs=[state, reflect], outputs=[state, current_task, progress, tip, reflect, cele])
    btn_skip.click(on_skip, inputs=[state], outputs=[state, current_task, progress, tip, cele])
    btn_export.click(on_export, inputs=[state], outputs=[file_out])
    btn_reset.click(on_reset, outputs=[state, current_task, progress, tip, file_out, cele])
    btn_reload.click(on_reload, inputs=[state], outputs=[state, tip])

if __name__ == "__main__":
    demo.launch(ssr_mode=False)